diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 0c5e005..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,99 +0,0 @@ -name: build and release - -on: - release: - types: [ created ] - - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - name: ${{ matrix.platform.os_name }} with rust ${{ matrix.toolchain }} - runs-on: ${{ matrix.platform.os }} - strategy: - fail-fast: false - matrix: - platform: - - os_name: Linux-aarch64 - os: ubuntu-latest - target: aarch64-unknown-linux-musl - bin: flash-linux-arm64 - - - os_name: Linux-x86_64 - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - bin: flash-linux-amd64 - - - os_name: Windows-x86_64 - os: windows-latest - target: x86_64-pc-windows-msvc - bin: flash-windows-amd64.exe - - - os_name: macOS-x86_64 - os: macOS-latest - target: x86_64-apple-darwin - bin: flash-darwin-amd64 - - - os_name: macOS-aarch64 - os: macOS-latest - target: aarch64-apple-darwin - bin: flash-darwin-arm64 - toolchain: - - stable - steps: - - uses: actions/checkout@v4 - - # Install dependencies for Linux platforms - - name: Install dependencies (Linux) - if: startsWith(matrix.platform.os_name, 'Linux') - run: sudo apt-get update && sudo apt-get install -y libudev-dev - - - name: Build binary - uses: houseabsolute/actions-rust-cross@v1 - with: - command: "build" - target: ${{ matrix.platform.target }} - toolchain: ${{ matrix.toolchain }} - args: "--locked --release" - strip: true - - - name: Rename binary (linux and macos) - run: mv target/${{ matrix.platform.target }}/release/flash target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} - if: matrix.platform.os_name != 'Windows-x86_64' - - name: Rename binary (windows) - run: mv target/${{ matrix.platform.target }}/release/flash.exe target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} - if: matrix.platform.os_name == 'Windows-x86_64' - - - name: Create release zip archive (Linux and macOS) - run: | - zip -j target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} - shell: bash - if: matrix.platform.os_name != 'Windows-x86_64' - - name: Create release zip archive (Windows) - run: | - powershell Compress-Archive -Path target\${{ matrix.platform.target }}\release\${{ matrix.platform.bin }} -DestinationPath target\${{ matrix.platform.target }}\release\${{ matrix.platform.bin }}.zip - if: matrix.platform.os_name == 'Windows-x86_64' - - - name: Generate SHA-256 - run: shasum -a 256 target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip | cut -d ' ' -f 1 > target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip.sha256 - - # Only upload as GitHub Release asset if this is a release event - - name: Release zip archive and SHA-256 checksum to GitHub - if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 - with: - files: | - target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip - target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip.sha256 - - # For manual runs, upload as workflow artifact for download - - name: Upload artifact for manual download - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.platform.bin }}.zip - path: target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.zip - retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a605a0a..7b5c455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,240 @@ name: build and release on: + release: + types: [created, published] workflow_dispatch: +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + jobs: - placeholder: - runs-on: ubuntu-latest + build: + name: ${{ matrix.platform.label }}-${{ matrix.platform.arch }} + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - label: linux + arch: x86_64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + cli_archive: wm-tools-cli-linux-x86_64.zip + gui_archive: dso3d12-gui-linux-x86_64.zip + gui_binary: dso3d12-gui + flash_binary: flash + capture_binary: capture + - label: windows + arch: x86_64 + runner: windows-latest + target: x86_64-pc-windows-msvc + cli_archive: wm-tools-cli-windows-x86_64.zip + gui_archive: dso3d12-gui-windows-x86_64.zip + gui_binary: dso3d12-gui.exe + flash_binary: flash.exe + capture_binary: capture.exe + - label: macos + arch: x86_64 + runner: macos-latest + target: x86_64-apple-darwin + cli_archive: wm-tools-cli-macos-x86_64.zip + gui_archive: dso3d12-gui-macos-x86_64.zip + gui_binary: dso3d12-gui + flash_binary: flash + capture_binary: capture + - label: macos + arch: arm64 + runner: macos-latest + target: aarch64-apple-darwin + cli_archive: wm-tools-cli-macos-arm64.zip + gui_archive: dso3d12-gui-macos-arm64.zip + gui_binary: dso3d12-gui + flash_binary: flash + capture_binary: capture + steps: - - name: Placeholder step - run: echo "Release workflow is a stub" + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + sudo apt-get update -y + sudo apt-get install -y \ + libasound2-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libudev-dev \ + libwayland-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxkbcommon-dev \ + libxrandr-dev \ + pkg-config + + - name: Build CLI (flasher + capture) + run: cargo build --locked --release -p wm-tools --target ${{ matrix.platform.target }} + + - name: Build GUI + run: cargo build --locked --release -p dso3d12-gui --target ${{ matrix.platform.target }} + + # --- CLI package --- + + - name: Stage Linux CLI package + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist/cli + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.flash_binary }}" dist/cli/ + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.capture_binary }}" dist/cli/ + cp LICENSE.txt README.md dist/cli/ + cp packaging/common/README-cli.txt dist/cli/PACKAGE.txt + (cd dist && zip -r "${{ matrix.platform.cli_archive }}" cli) + + - name: Stage macOS CLI package + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist/cli + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.flash_binary }}" dist/cli/ + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.capture_binary }}" dist/cli/ + cp LICENSE.txt README.md dist/cli/ + cp packaging/common/README-cli.txt dist/cli/PACKAGE.txt + ditto -c -k --keepParent dist/cli "dist/${{ matrix.platform.cli_archive }}" + + - name: Stage Windows CLI package + if: runner.os == 'Windows' + shell: pwsh + run: | + Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path dist/cli | Out-Null + Copy-Item "target/${{ matrix.platform.target }}/release/${{ matrix.platform.flash_binary }}" dist/cli/ + Copy-Item "target/${{ matrix.platform.target }}/release/${{ matrix.platform.capture_binary }}" dist/cli/ + Copy-Item LICENSE.txt, README.md dist/cli/ + Copy-Item packaging/common/README-cli.txt dist/cli/PACKAGE.txt + Compress-Archive -Path dist/cli/* -DestinationPath "dist/${{ matrix.platform.cli_archive }}" -Force + + # --- GUI package --- + + - name: Stage Linux GUI package + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + mkdir -p dist/gui + cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.gui_binary }}" dist/gui/ + cp LICENSE.txt README.md dist/gui/ + cp packaging/common/README-gui.txt dist/gui/PACKAGE.txt + cp packaging/linux/README.txt dist/gui/PLATFORM.txt + (cd dist && zip -r "${{ matrix.platform.gui_archive }}" gui) + + - name: Stage Windows GUI package + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Path dist/gui -Force | Out-Null + Copy-Item "target/${{ matrix.platform.target }}/release/${{ matrix.platform.gui_binary }}" dist/gui/ + Copy-Item LICENSE.txt, README.md dist/gui/ + Copy-Item packaging/common/README-gui.txt dist/gui/PACKAGE.txt + Copy-Item packaging/windows/README.txt dist/gui/PLATFORM.txt + Compress-Archive -Path dist/gui/* -DestinationPath "dist/${{ matrix.platform.gui_archive }}" -Force + + - name: Stage macOS GUI package + if: runner.os == 'macOS' + shell: bash + env: + APP_VERSION: ${{ github.event.release.tag_name || github.ref_name }} + run: | + set -euo pipefail + mkdir -p dist + ./packaging/macos/create_app_bundle.sh \ + --dmg \ + --output-dir dist \ + --binary-path "target/${{ matrix.platform.target }}/release/dso3d12-gui" \ + --version "${APP_VERSION:-0.0.0}" + cp packaging/common/README-gui.txt dist/README-gui.txt + cp packaging/macos/README-unsigned.txt dist/README-unsigned.txt + ditto -c -k --sequesterRsrc --keepParent dist/DSO3D12.app "dist/${{ matrix.platform.gui_archive }}" + + # --- Checksums --- + + - name: Generate checksums + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + shasum -a 256 "dist/${{ matrix.platform.cli_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.cli_archive }}.sha256" + shasum -a 256 "dist/${{ matrix.platform.gui_archive }}" | awk '{print $1}' > "dist/${{ matrix.platform.gui_archive }}.sha256" + # Bundle CLI zip + sha256 into a single archive + (cd dist && zip "${{ matrix.platform.cli_archive }}-bundle.zip" "${{ matrix.platform.cli_archive }}" "${{ matrix.platform.cli_archive }}.sha256") + # Bundle GUI zip + sha256 into a single archive + (cd dist && zip "${{ matrix.platform.gui_archive }}-bundle.zip" "${{ matrix.platform.gui_archive }}" "${{ matrix.platform.gui_archive }}.sha256") + if [[ "${{ runner.os }}" == "macOS" ]]; then + shasum -a 256 "dist/DSO3D12.dmg" | awk '{print $1}' > "dist/DSO3D12.dmg.sha256" + fi + + - name: Generate checksums (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + (Get-FileHash "dist/${{ matrix.platform.cli_archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.cli_archive }}.sha256" + (Get-FileHash "dist/${{ matrix.platform.gui_archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.gui_archive }}.sha256" + # Bundle CLI zip + sha256 + Compress-Archive -Path "dist/${{ matrix.platform.cli_archive }}","dist/${{ matrix.platform.cli_archive }}.sha256" -DestinationPath "dist/${{ matrix.platform.cli_archive }}-bundle.zip" -Force + # Bundle GUI zip + sha256 + Compress-Archive -Path "dist/${{ matrix.platform.gui_archive }}","dist/${{ matrix.platform.gui_archive }}.sha256" -DestinationPath "dist/${{ matrix.platform.gui_archive }}-bundle.zip" -Force + + # --- Upload --- + + - name: Release artifacts + if: github.event_name == 'release' && runner.os != 'macOS' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/${{ matrix.platform.cli_archive }}-bundle.zip + dist/${{ matrix.platform.gui_archive }}-bundle.zip + + - name: Release macOS artifacts + if: github.event_name == 'release' && runner.os == 'macOS' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/${{ matrix.platform.cli_archive }}-bundle.zip + dist/${{ matrix.platform.gui_archive }}-bundle.zip + dist/DSO3D12.dmg + dist/DSO3D12.dmg.sha256 + dist/README-unsigned.txt + + - name: Upload artifacts (manual run) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v7 + with: + name: packages-${{ matrix.platform.label }}-${{ matrix.platform.arch }} + path: | + dist/${{ matrix.platform.cli_archive }} + dist/${{ matrix.platform.cli_archive }}.sha256 + dist/${{ matrix.platform.gui_archive }} + dist/${{ matrix.platform.gui_archive }}.sha256 + dist/DSO3D12.dmg + dist/DSO3D12.dmg.sha256 + dist/README-unsigned.txt + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0cace22 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,57 @@ +name: Pre-merge testing + +on: + pull_request: + branches: ["**"] + push: + branches: [main, master] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update -y + sudo apt-get install -y \ + libasound2-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libudev-dev \ + libwayland-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxkbcommon-dev \ + libxrandr-dev \ + pkg-config + + - name: Build workspace + run: cargo build --workspace + + - name: Run tests + run: cargo test --workspace + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/.gitignore b/.gitignore index 3da2ca5..e6becf5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,8 @@ *.orig *.rej -./firmware *.fls + +prompts/ +scripts/ +firmware/ diff --git a/Cargo.lock b/Cargo.lock index 9416edd..c095b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,111 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] name = "anstream" @@ -59,595 +164,5606 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "bitflags" -version = "1.3.2" +name = "arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] -name = "bitflags" -version = "2.11.1" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] -name = "bumpalo" -version = "3.20.3" +name = "arg_enum_proc_macro" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "bytecount" -version = "0.6.9" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "arrayvec" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "clap" -version = "4.6.1" +name = "as-raw-xcb-connection" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] -name = "clap_builder" -version = "4.6.0" +name = "as-slice" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "stable_deref_trait", ] [[package]] -name = "clap_derive" -version = "4.6.1" +name = "ash" +version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "libloading", ] [[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "colored" -version = "3.1.1" +name = "ashpd" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ - "windows-sys 0.61.2", + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", ] [[package]] -name = "console" -version = "0.16.3" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "core-foundation" -version = "0.10.1" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "core-foundation-sys", - "libc", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "crc" -version = "3.4.0" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "crc-catalog", + "async-lock", + "blocking", + "futures-lite", ] [[package]] -name = "crc-catalog" -version = "2.5.0" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] [[package]] -name = "fnv" -version = "1.0.7" +name = "async-net" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] [[package]] -name = "futures-core" -version = "0.3.32" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "async-recursion" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", "futures-core", - "futures-task", - "pin-project-lite", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", "slab", + "windows-sys 0.61.2", ] [[package]] -name = "heck" -version = "0.5.0" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] -name = "indicatif" -version = "0.18.4" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "io-kit-sys" -version = "0.4.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" -dependencies = [ - "core-foundation-sys", - "mach2", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "js-sys" -version = "0.3.99" +name = "av-scenechange" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", ] [[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libudev" -version = "0.3.0" +name = "av1-grain" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" dependencies = [ - "libc", - "libudev-sys", + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", ] [[package]] -name = "libudev-sys" -version = "0.1.4" +name = "avif-serialize" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ - "libc", - "pkg-config", + "arrayvec", ] [[package]] -name = "mach2" -version = "0.4.3" +name = "base62" +version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" [[package]] -name = "nix" -version = "0.26.4" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", + "bit-vec", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "bit_field" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] -name = "papergrid" -version = "0.17.0" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" -dependencies = [ - "bytecount", - "fnv", - "unicode-width", -] +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] -name = "pkg-config" -version = "0.3.33" +name = "bitstream-io" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "block" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "block2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "proc-macro2", - "quote", + "objc2 0.5.2", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", + "objc2 0.6.4", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "unicode-ident", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "quote" -version = "1.0.45" +name = "bstr" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ - "proc-macro2", + "memchr", + "serde_core", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "built" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "serialport" -version = "4.9.0" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "core-foundation", - "core-foundation-sys", - "io-kit-sys", - "libudev", - "mach2", - "nix", - "scopeguard", - "unescaper", - "windows-sys 0.52.0", + "bytemuck_derive", ] [[package]] -name = "slab" -version = "0.4.12" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "strsim" -version = "0.11.1" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "syn" -version = "2.0.117" +name = "byteorder-lite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] -name = "tabled" -version = "0.20.0" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "papergrid", - "tabled_derive", - "testing_table", + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", ] [[package]] -name = "tabled_derive" -version = "0.11.0" +name = "calloop" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "heck", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", ] [[package]] -name = "testing_table" +name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "unicode-width", + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "calloop-wayland-source" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "thiserror-impl", + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "cc" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ - "proc-macro2", - "quote", - "syn", + "find-msvc-tools", + "jobserver", + "libc", + "shlex", ] [[package]] -name = "unescaper" -version = "0.1.8" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" -dependencies = [ - "thiserror", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "unicode-width" +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] [[package]] -name = "unit-prefix" -version = "0.5.2" +name = "ciborium-io" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" [[package]] -name = "utf8parse" +name = "ciborium-ll" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] [[package]] -name = "wasm-bindgen" -version = "0.2.122" +name = "clap" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "clap_builder", + "clap_derive", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" +name = "clap_builder" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" +name = "clap_derive" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "bumpalo", + "heck", "proc-macro2", "quote", "syn", - "wasm-bindgen-shared", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", -] +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "web-time" -version = "1.1.0" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ - "js-sys", - "wasm-bindgen", + "error-code", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "clipboard_macos" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "clipboard_wayland" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" dependencies = [ - "windows-targets", + "smithay-clipboard", ] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "clipboard_x11" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" dependencies = [ - "windows-link", + "thiserror 2.0.18", + "x11rb", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "codespan-reporting" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "serde", + "termcolor", + "unicode-width", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "color_quant" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "colored" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.11.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.2", + "wgpu", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dso-parser" +version = "0.2.1" +dependencies = [ + "chrono", + "ciborium", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "dso3d12-gui" +version = "0.2.1" +dependencies = [ + "ab_glyph", + "anyhow", + "chrono", + "crc", + "csv", + "dirs", + "dso-parser", + "font-kit", + "iced", + "iced_aw", + "log", + "open", + "png 0.17.16", + "rfd", + "rust-i18n", + "serde", + "serde_json", + "serialport", + "sys-locale", + "thiserror 2.0.18", + "tiny-skia 0.12.0", + "tokio", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "core-foundation 0.9.4", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "image", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_aw" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b7f923642b024c415150b70ef84ee5c11626e3b0af73b954d817d573bcd0a4" +dependencies = [ + "cfg-if", + "chrono", + "iced_core", + "iced_fonts", + "iced_widget", + "num-format", + "num-traits", + "web-time", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.2", + "smol_str", + "thiserror 2.0.18", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_fonts" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214cff7c8499e328774216690e58e315a1a5f8f6fdd1035aed6298e62ffc4c1d" +dependencies = [ + "iced_core", + "iced_fonts_macros", + "iced_widget", +] + +[[package]] +name = "iced_fonts_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef5125e110cb19cd1910a28298661c98c5d9ab02eef43594968352940e8752e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "ttf-parser", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.2", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "image", + "kamadak-exif", + "log", + "lyon_path", + "raw-window-handle", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.2", + "softbuffer", + "tiny-skia 0.11.4", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "lyon", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.2", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "lilt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67562e5eff6b20553fa9be1c503356768420994e28f67e3eafe6f41910e57ad" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml", + "triomphe", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" @@ -679,6 +5795,179 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wm-tools" version = "0.1.2" @@ -687,7 +5976,335 @@ dependencies = [ "clap", "colored", "crc", + "dso-parser", "indicatif", "serialport", "tabled", ] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml index b19c84f..7ad725f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,22 @@ -[package] -name = "wm-tools" -version = "0.1.2" +[workspace] +resolver = "2" +members = [ + "flasher", + "dso-parser", + "gui", +] + +[workspace.package] +version = "0.2.1" edition = "2021" -description = "A tool for Winner Micro MCU (including DSO 3D12 oscilloscope firmware)" +description = "Tools for ZeeWeii DSO3D12 oscilloscope and Winner Micro MCUs" +license = "MIT" repository = "https://github.com/rssdev10/wm-tools" -[[bin]] -name = "flash" -path = "src/flash.rs" - -[dependencies] +[workspace.dependencies] anyhow = "1.0" -clap = { version = "4.0", features = ["derive"] } -colored = "3.0" -crc = "3.0" -indicatif = "0.18" -serialport = "4.9.0" -tabled = "0.20" +thiserror = "2.0" +log = "0.4" +rust-i18n = "3" +png = "0.17" +tiny-skia = "0.12" diff --git a/README.md b/README.md index 6ac318c..8d7c052 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ https://github.com/winnermicro/wm_iot_sdk/tree/master ## Features +### CLI flasher (`flash`) + - Auto-detects the highest supported baud rate (up to 2 Mbps) - XMODEM-1K protocol with CRC-16 verification - Progress bar with ETA during flashing @@ -14,6 +16,14 @@ https://github.com/winnermicro/wm_iot_sdk/tree/master - Optional flash erase before writing - DTR/RTS-based automatic device reset +### GUI viewer (`dso3d12-gui`) + +A graphical application that allows you to visualize and save dumps and screenshots from the ZeeWeii DSO3D12 oscilloscope. + +Once connected, the application automatically retrieves debug dumps and snapshot view data from the device, provided these features have been activated via the device buttons. These graphs can be saved as PNG files or exported as CSV data. + +To use the snapshot view feature, you must install the modified firmware. For more details, visit [ZeeTweak](https://github.com/taligentx/ZeeTweak). The debug dump export feature is available by default. + ## Requirements - Rust toolchain (stable, 2021 edition or later) @@ -25,7 +35,19 @@ https://github.com/winnermicro/wm_iot_sdk/tree/master cargo build --release ``` -The binary is produced at `target/release/flash`. +The binaries are produced at: + +| Binary | Path | +|--------|------| +| CLI flasher | `target/release/flash` | +| CLI capture | `target/release/capture` | +| GUI viewer | `target/release/dso3d12-gui` | + +### Run the GUI from source + +```bash +cargo run --bin dso3d12-gui +``` ## How to use diff --git a/docs/GUI_DESIGN.md b/docs/GUI_DESIGN.md new file mode 100644 index 0000000..9e1648c --- /dev/null +++ b/docs/GUI_DESIGN.md @@ -0,0 +1,245 @@ +# DSO3D12 Viewer — GUI Design + +This document describes the GUI layout, interaction model and state of the +`dso3d12-gui` application. **Update this document whenever the GUI changes.** + +## Window layout + +``` +┌───────────────────────────────────────────────────────────────┐ +│ DSO3D12 │ Port: [▾] [Start Listening] STATUS │ ← menu bar +├──────────────────────────────────────────────┬────────────────┤ +│ │ Measurement │ +│ │ ──────────── │ +│ GRAPH CANVAS │ CH1 V: … │ +│ (oscilloscope grid + │ Time range: … │ +│ traces + cursors) │ ──────────── │ +│ │ ☐ Cursor X │ +│ │ [═══●═════●═] │ +│ │ Δt, freq Hz │ +│ │ │ +│ │ ☐ Cursor Y │ +│ │ [═══●═════●═] │ +│ │ ΔV in volts │ +│ │ ──────────── │ +│ │ CH1/2 stats │ +├──────────────────────────────────────────────┴────────────────┤ +│ ☑ CH1 ☑ CH2 │ ☐ Device X ☐ Device Y │ View: [Line ▾] │ ← data controls +├───────────────────────────────────────────────────────────────┤ +│ [#1 (60000s)] [#2 (60000s)] [#3] … │ ← thumbnails +├───────────────────────────────────────────────────────────────┤ +│ [Load Capture] [Save Capture] │ [Save PNG] [Export Data] │ │ ← actions +│ [App Settings] │ +├───────────────────────────────────────────────────────────────┤ +│ ☑ Show instructions │ +│ ┌── How to capture from a ZeeWeii DSO3D12 ──────────────────┐ │ +│ └───────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ +``` + +## Panels + +### 1. Menu bar (top) +* Application label. +* `Port` `pick_list` of detected serial ports (auto-scanned every 10 s). +* Last-used port shown as text. +* `Listen` / `Stop` toggle button — starts/stops the background serial + listener thread that auto-detects captures. +* Right-aligned **status text** ("Ready.", "Listening…", "Received N captures", + "Receiving… 1234 bytes", error messages). +* Version number shown in window title. + +### 2. Graph canvas +Custom `iced::widget::canvas` (`canvas.rs`, `Scope`). 10×8 grid, centred axes, +yellow CH1, cyan CH2. Renders: +* Traces in **Line / Dot / Smooth** mode (smooth = moving average). +* Pink interactive cursors (range-shaded) for **measurement cursors**. + Cursors can also be moved by clicking/dragging directly on the graph + (nearest cursor line snaps to click position). +* Green thin lines for **device cursors** (data-supplied, read-only) with + numeric labels (sample numbers for X, voltage for Y). +* Optional **axis scales** toggled via "Show scales" setting. Scale values + are derived from user-configurable V/div and time/div parameters: + top grid line = V/off + 4×V/div, center = V/off, bottom = V/off − 4×V/div. + V/off affects voltage labels and cursor measurements. The trace Y-position + uses the device pixel density (25): `y = v / 200 × height`. + +### 3. Measurement panel (right, 220 px) +Vertical column inside a rounded container. + +**Scale configuration** (always visible, two-column layout with CH1/CH2 headers): +* **V/div** text inputs — volts per division per channel (default 1.0 V each). With "V" unit. +* **V/off** text inputs — voltage offset at graph center per channel (default 0.0 V each). With "V" unit. +* **t/div** text input — time per division in ms (shared, default 1.0 ms). With "ms" unit. +* Label: "AC mode: 8×V, 12×t divs" — documents the scope grid geometry. + +These values drive all voltage/time calculations throughout the panel and +graph scales. The oscilloscope screen has 8 vertical and 12 horizontal divs. +When capture metadata includes probe-mode information (×1, ×10, ×100), a +**Probe:** row is shown beneath V/div, and probe + coupling info appear in the +Trigger section. + +When a capture arrives via the **screenshot protocol** (live), V/div, V/offset +and t/div are **automatically filled** from the packet's embedded settings +metadata. The same auto-population applies when **switching between captures** +in the thumbnail strip — clicking a screenshot capture applies its `scope_state` +settings. For single-capture `.zwcap` or `.bin` files, settings are applied +automatically on load. For multi-capture files, select the desired capture +from the thumbnails to apply its settings. ASCII dumps (without metadata) +retain the last-used settings. + +**Signal information** (shown when a capture is loaded): +* Voltage range for CH1/CH2 (computed from V/div × 8 divs, centered at 0). +* Time range (computed from t/div × 12 divs) + sample count. + +**Two range sliders**, one per axis, each a custom widget (`range_slider.rs`) +drawn with `canvas::Program`. Each slider has **two knobs** (low, high); the +user drags whichever knob is nearer. + +Below each slider the panel shows cursor measurements: +* X cursors → Δt in ms + frequency in Hz (using real t/div scale). +* Y cursors → ΔV in volts + absolute voltage pair per channel (using each + channel's V/div and V/offset scale independently). + +A divider, then per-channel basic stats (`n`, `min`, `max`, `avg`). + +**Trigger info** (shown when capture comes from screenshot format): +* Mode (Auto/Normal/Single), Edge, Source. +* Level in volts. +* Delay + sample rate. + +**Auto-measurements table** (shown when screenshot measurements available): +* Two-column table (CH1, CH2). +* Rows: Freq, PkPk, Avg, RMS, Amp, +Duty, -Duty, +T, -T, T, Max, Min, Top, Base. +* Values formatted in physical units (V, Hz, s). + +### 4. Data-controls panel (below graph) +* CH1 / CH2 visibility toggles. +* **Device cursor** toggles — **disabled** when no device-cursor data in capture. +* View-mode `pick_list`: `Line`, `Dot`, `Smooth`. + +### 5. Thumbnails strip +`scrollable` horizontal row of buttons, one per loaded capture showing sample +count. The active capture uses `primary` style. Clicking loads that capture +into the main graph. + +### 6. Action buttons row +`Load Capture` · `Save Capture` │ `Save PNG` · `Export Data` │ +`Application Settings`. + +### 7. Instructions panel +Toggleable two-column panel. Visibility persisted in settings. +* **Left column** — Standard firmware: how to trigger a raw debug dump + (long-press Save in measurement view). +* **Right column** — Screenshot firmware: how to transmit a screenshot packet + (Gallery → select waveform). + +### 8. Settings dialog +Full-page replacement view (el15 card-based style). Sections: + +**Serial** (bordered card): +* Default serial port (text input, shows available ports). +* Baud rate (text input, default 115200). +* Auto-listen on startup toggle. +* Beep on new capture toggle. + +**Display** (bordered card): +* Show axis scales toggle. +* Show instructions panel toggle. + +**About** (bordered card): +* App name + version. +* GitHub link. +* Config directory path. +* "Firmware Update…" button opens firmware dialog. + +"Save & Close" button writes to JSON and returns to main view. + +### 9. Firmware update dialog +Full-page replacement view with: +* Warning banner about bricking risk. +* Port picker (pick_list of available ports). +* File browser button for .bin firmware selection. +* Progress bar during flash. +* Status text with step-by-step updates. +* Start Flash / Close buttons. +* Uses XMODEM-1K protocol (same as CLI flasher) in a background thread. + +## Cursor model + +| Kind | Source | Editable | UI location | +|--------------|-----------------|----------|---------------------| +| Measurement | User (sliders) | Yes | Right-hand panel | +| Device | Capture data | No | Data-controls panel | + +Never share state between the two. + +## Serial port + +* Uses `serialport` crate: 115200 baud, 8N1, no flow control. +* Background thread (`serial.rs`) reads continuously. +* **Two capture protocols are auto-detected:** + 1. **ASCII debug dump** — triggered by long-press Save on the device. + Variable size (~150–500 kB); detected by `--------CH1--------` marker. + Contains 60 000 raw samples per channel. + 2. **Binary screenshot packet** — triggered by opening a saved waveform in + the device Gallery. Fixed size 2048 bytes; detected by size (2034–2056 bytes) + after a 200 ms of serial silence. + Contains 300 display pixels (min + max ADC per pixel) plus rich settings. +* Capture detection: 500 ms timeout with no new bytes → parse accumulated buffer. +* Auto-reconnect: on disconnect, retries every 2 s until stopped. +* Selected port persisted in settings; app tries to reconnect on startup. + +## PNG export + +`Save PNG` renders a 1200×600 pixel image of the current capture with grid + +traces, written as a valid PNG file using a minimal built-in encoder (no +external image crate needed). Filename is auto-generated as +`capture_YYYYMMDD_HHMMSS.png` saved to the Downloads directory (no file picker). + +## Persisted state (`settings.json`) + +```jsonc +{ + "serial_port": "/dev/tty.usbserial-1110", + "baud_rate": 115200, + "show_instructions": true, + "show_ch1": true, "show_ch2": true, + "measurement_cursor_x_enabled": false, + "measurement_cursor_y_enabled": false, + "device_cursor_x_enabled": false, + "device_cursor_y_enabled": false, + "view_mode": "Line", + "cursor_x_range": [0.3, 0.7], + "cursor_y_range": [0.3, 0.7], + "v_per_div_ch1": 1.0, "v_per_div_ch2": 1.0, + "v_offset_ch1": 0.0, "v_offset_ch2": 0.0, + "show_scales": false, + "alert_on_data": true, + "auto_listen": false, + "v_per_div": 1.0, + "t_per_div_ms": 1.0, + "v_offset": 0.0, + "split_png": false +} +``` + +## Logging + +`logging.rs` — console (stderr) logger only by default. File logging to +`/dso3d12-gui/dso3d12-gui.log` can be enabled by setting +environment variable `DSO_LOG_FILE=1`. Rotates at ~1 MB. +Default level: info; override with `RUST_LOG`. + +## Voltage & time assumptions + +* ADC 0..255 uses screen-coordinate convention (0 = top of screen ≈ +5 V, + 127/128 = 0 V, 255 = bottom of screen ≈ −5 V). This is consistent across + both debug-dump and screenshot data paths. +* Voltage uses the device-calibrated formula: + `V = V_offset + (127 − v) × V/div / 25` + where `v` = screen-coordinate ADC value (0 = top, 255 = bottom) and + 25 = pixels per division. This matches the DSO3D12's own `pixel_to_volts`. +* Time axis = 12 divisions × t/div (ms). +* When receiving screenshot packets or loading `.zwcap` files with metadata, + V/div, V/offset, t/div and probe mode are auto-filled from device metadata. diff --git a/docs/SCREENSHOT_PROTOCOL.md b/docs/SCREENSHOT_PROTOCOL.md new file mode 100644 index 0000000..42e6407 --- /dev/null +++ b/docs/SCREENSHOT_PROTOCOL.md @@ -0,0 +1,299 @@ +# Screenshot Packet — Data Analysis Report + +## Purpose + +This report documents the ZeeWeii DSO3D12 **screenshot binary protocol**, +the format of the 2048-byte packet, and how the real captured data can be +used for accurate waveform drawing. + +The implementation is in `dso3d12-parser/src/screenshot.rs`. +The capture CLI tool is `flasher/src/capture.rs`. + +--- + +## Protocol Overview + +### Trigger + +Open the device **Gallery** (Screenshots view) and select a saved waveform. +The device automatically transmits a 2048-byte binary packet over the +USB-serial port at 115200 baud, 8N1. + +This is entirely separate from the ASCII debug dump (triggered by long-press +Save in measurement view). The two protocols should **never be confused**: + +| Property | Debug dump (ASCII) | Screenshot packet (binary) | +|-------------------|-------------------------|----------------------------| +| Size | ~150–500 kB variable | **2048 bytes fixed** | +| Encoding | ASCII decimal | Binary little-endian | +| Samples/channel | 60 000 (full buffer) | **300** (12 div × 25 px) | +| Sample type | One sample = one byte | min/max per display pixel | +| Metadata | None | Rich instrument settings | +| Trigger | Long-press Save | Gallery → select waveform | + +--- + +## Packet Layout (2048 bytes) + +``` +Offset Size Field +───────────────────────────────────────────────────────────────── + 0 300 CH1 waveform minimum ADC values (u8 × 300) + 300 300 CH1 waveform maximum ADC values (u8 × 300) + 600 300 CH2 waveform minimum ADC values (u8 × 300) * + 900 300 CH2 waveform maximum ADC values (u8 × 300) * +1200 340 Oscilloscope settings (DATA_DSO3D12 struct) +1540 100 CH1 auto-measurements (12 × i64 + 2 × i16 LE) +1640 100 CH2 auto-measurements * +1740 308 Reserved / padding +───────────────────────────────────────────────────────────────── +Total: 2048 bytes +* CH2 data is meaningless (but present) when CH2 is disabled. +``` + +### Why min + max? + +The device downsamples the capture buffer (30 000–60 000 samples) to fit the +300-pixel display width. Each pixel covers multiple samples; the device stores +both the **minimum** and **maximum** sample value within that pixel's time +window. This is called an **envelope** representation, and it allows the display +to show waveform transitions that would otherwise alias away at lower resolution. + +For a clean single-line trace: average `(min + max) / 2`. +For a "filled" oscilloscope-style trace: fill between `min` and `max`. + +--- + +## ADC-to-Voltage Conversion + +Raw ADC values are **inverted and zero-centred**: the ADC stores `255 - signal`. +The zero-volt reference is not necessarily at ADC code 128; it is shifted by the +device-calibrated `zero_volt_pixels` offset. + +``` +pixel_val = 255 - raw # uninvert +voltage(V) = (pixel_val - 128 - zero_volt_pixels) × (volt_scale_uv / 25) / 1e6 + × probe_multiplier +``` + +Where `volt_scale_uv` is the µV per **division** (from the VDIV table) and +division = 25 pixels. Probe multiplier: ×1=1, ×10=10, ×100=100. + +--- + +## Key Settings Fields (byte offsets) + +| Offset | Type | Field | Notes | +|--------|-------|--------------------------------------|--------------------------| +| 1200 | u8 | run_state | 0=Stop 1=Run 2=Wait | +| 1201 | u8 | system_state | | +| 1202 | u8 | ch2_enabled | 0=off 1=on | +| 1204 | i64 | buffer_depth (samples) | DSO3D12 only | +| 1216 | u8 | timebase_index | 2=5ns … 30=10s | +| 1220 | u64 | timebase_ns_per_div | ns per division | +| 1228 | i64 | horiz_trigger_delay_ps | | +| 1252 | u32 | sample_rate_hw_hz / 100 | DSO3D12 (×100 to get Hz) | +| 1256 | u8 | trigger_source | 0=CH1 1=CH2 | +| 1257 | u8 | trigger_mode | 0=Auto 1=Normal | +| 1258 | u8 | trigger_edge | 0=Rise 1=Fall | +| 1264 | u16 | trigger_level_adc | raw ADC count | +| 1266 | u8 | ch1_vdiv_index | 1–13 | +| 1268 | u32 | ch1_volt_scale_uv | µV per division | +| 1272 | i32 | ch1_zero_volt_pixels | zero-ref pixel offset | +| 1276 | i64 | ch1_zero_volt_uv | zero-ref in µV | +| 1304 | u8 | ch1_coupling | 0=DC 1=AC | +| 1305 | u8 | ch1_probe_mode | 0=×1 1=×10 2=×100 | +| 1328 | u8 | ch2_vdiv_index | | +| 1332 | u32 | ch2_volt_scale_uv | | +| 1336 | i32 | ch2_zero_volt_pixels | | +| 1392 | u64 | sample_rate_meas_hz | measurement sample rate | +| 1400 | u32 | pre_trigger_samples | | +| 1404 | u32 | post_trigger_samples | | +| 1408 | u16 | display_start_col | viewport start | +| 1410 | u16 | display_end_col | viewport end | +| 1453 | u8 | cursors_x_enable | | +| 1454 | u8 | cursors_y_enable | | +| 1456 | i16 | cursor_x1 (pixels, +right of centre) | | +| 1458 | i16 | cursor_x2 | | +| 1460 | i16 | cursor_y1 (pixels, signed) | | +| 1462 | i16 | cursor_y2 | | + +--- + +## Measurement Block Layout (100 bytes, little-endian) + +``` +Bytes 0– 95 : 12 × i64 (8 bytes each) + [0] PkPk amplitude (µV) + [1] Amplitude (µV) + [2] Top (µV) + [3] Base (µV) + [4] Max (µV) + [5] Min (µV) + [6] Average (µV) + [7] RMS (µV) + [8] Positive width (ps) + [9] Negative width (ps) + [10] Period (ps) + [11] Frequency (µHz → divide by 1e6 to get Hz) + +Bytes 96– 97 : i16 Positive duty cycle (units: 0.1%) +Bytes 98– 99 : i16 Negative duty cycle (units: 0.1%) +``` + +--- + +## Real Capture — Data Analysis + +Captured 2025-05-31 from a DSO3D12 via `/dev/tty.usbserial-1110`. + +### Instrument state + +| Field | Value | +|--------------------|------------------------------------| +| Device | DSO3D12 | +| Run state | Run | +| Timebase | 500 µs/div (index 17) | +| Timebase raw | 500 000 ns/div | +| Time/pixel | 20 µs/px (500 µs ÷ 25 px/div) | +| Total time span | 6.00 ms (300 px × 20 µs/px) | +| Buffer depth | 30 000 samples | +| Pre-trigger | 15 000 samples | +| Sample rate (meas) | 5.00 MSa/s | +| Sample rate (HW) | 0 (field at offset 1252 is 0 here) | + +### Channel 1 + +| Field | Value | +|--------------------|----------------------| +| V/div | 1 V/div (index 7) | +| Coupling | AC | +| Probe | ×10 | +| Volt scale | 100 000 µV/div | +| Volt scale/pixel | 4 000 µV/px (4 mV/px)| +| Zero offset (px) | 20 px | +| Zero offset (µV) | 80 mV | +| Display range | ≈ −4.0 V … +4.0 V | + +**Waveform:** min=−2.22 V, max=+0.34 V, avg=−0.94 V + +**Measurements:** 1 kHz frequency, 1.00 ms period, 50% duty cycle, +2.60 V peak-to-peak. This is a ~1 kHz square wave viewed in AC coupling +mode on a ×10 probe, with a small DC offset showing the AC coupling effect +(average is not 0 V because the probe/coupling haven't fully settled). + +### Channel 2 + +| Field | Value | +|--------------------|------------------------| +| V/div | 200 mV/div (index 8) | +| Coupling | DC | +| Probe | ×1 | +| Volt scale/pixel | 8 000 µV/px (8 mV/px) | +| Zero offset (px) | 0 px | +| Display range | ≈ −0.8 V … +0.8 V | + +**Waveform:** min=−0.18 V, max=−0.17 V, avg=−0.176 V + +**Measurements:** Essentially a DC offset of −176 mV. The very small +min/max spread (10 mV) is consistent with noise floor on a DC signal. + +### Raw ADC bytes (CH1 min, first 16) + +``` +A1 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 +``` + +0xA3 = 163 decimal. Converted: `pixel = 255 - 163 = 92`. Voltage: +`(92 - 128 - 20) × 4000 µV/px × 10 (probe) = (-56) × 40 mV = -2.24 V`. ✓ + +--- + +## How This Data Drives Graph Drawing + +### X axis + +``` +t[i] = i × (timebase_ns_per_div / 25) / 1e9 seconds +``` + +For this capture: `t[i] = i × 20 µs`, total span 0 … 5.98 ms. + +### Y axis — voltage (per channel) + +``` +pixel_val = 255 - raw +voltage = (pixel_val - 128 - zero_volt_pixels) × (volt_scale_uv / 25) / 1e6 + × 10^probe_mode +``` + +The `zero_volt_pixels` value calibrates the position of 0 V on the ADC scale +and must be subtracted — it represents the hardware DC offset of the channel. + +### Waveform envelope (min/max shading) + +Each pixel delivers a `(min_v, max_v)` range: +* **Simple trace**: draw the midpoint `(min_v + max_v) / 2`. +* **Envelope fill**: fill the area between `min_v` and `max_v` using a + semi-transparent band — this is what the device's own screen does. +* **High-density mode**: draw both min and max as separate thin lines. + +### Y-axis range for the graph widget + +The full visible range from the device settings is: + +``` +half_range = volt_scale_uv × 4 / 1e6 × probe_multiplier (V) +y_min = zero_volt_uv/1e6 - half_range +y_max = zero_volt_uv/1e6 + half_range +``` + +For this capture's CH1: `half_range = 100 000 × 4 / 1e6 × 10 = 4 V`, +so the display range is −4 V … +4 V. + +### Auto-populating V/div and t/div in the GUI + +After parsing a screenshot packet the GUI can auto-populate its measurement +panel inputs directly from device settings: + +``` +v_per_div = volt_scale_uv as f64 / 1_000_000.0 × probe_multiplier +t_per_div_ms = timebase_ns_per_div as f64 / 1_000_000.0 (ns → ms) +``` + +This eliminates the need for the user to manually enter scale values. + +--- + +## Device Variant Field Differences + +| Field | DSO3D12 offset | DSO2512G V2/V1 offset | +|--------------------|----------------|-----------------------| +| buffer_depth | 1204 (i64) | 1208 (u64) | +| sample_rate_hw | 1252 (u32×100) | 1256 (u64×100) | +| meas CH1 block | 1540 | 1544 | +| meas CH2 block | 1640 | 1648 | + +--- + +## Observations & Known Issues + +1. **`sample_rate_hw` reads 0 on this unit.** The field at offset 1252 + (DSO3D12) contains zeros in this firmware version. The measurement sample + rate at offset 1392 (5.00 MSa/s) is correct and more reliable. + +2. **CH1 avg ≠ 0 in AC mode.** The average of −0.94 V is because the + waveform is captured near a DC transition — AC coupling capacitor has + not fully settled, or this is expected asymmetric clipping. + +3. **CH2 shows no measurements.** The measurement fields (freq, period, etc.) + are zero for CH2. The device only computes auto-measurements for the active + measurement channel. The raw waveform data (min/max ADC) is correct. + +4. **Cursor data absent.** Both `cursors_x_enable` and `cursors_y_enable` are + 0 in this capture. Cursor fields will be non-zero when device cursors are + enabled on the scope before saving. + +5. **Waveform pixel count is always 300.** The 300-sample constraint is the + display width — independent of buffer depth or timebase. Longer captures + are downsampled by the device firmware to 300 pixels. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md new file mode 100644 index 0000000..01ed801 --- /dev/null +++ b/docs/USER_MANUAL.md @@ -0,0 +1,225 @@ +# DSO3D12 Viewer — User Manual + +A desktop application to view, measure and export waveform captures from the +ZeeWeii DSO3D12 pocket oscilloscope. + +## Installing + +```bash +cargo install --path gui +``` + +Or during development: + +```bash +cargo run -p dso3d12-gui +``` + +## Connecting to the oscilloscope + +1. Connect the DSO3D12 via USB (a USB-to-serial adapter appears as e.g. + `/dev/tty.usbserial-1110` on macOS). +2. In the top menu bar, select the port from the **Port** dropdown. +3. Click **Start Listening**. + +The status bar shows "Listening on …". The app will now automatically detect +and display captures when you trigger a dump from the device. + +### Triggering a capture on the device + +There are two independent capture methods: + +#### Method 1 — ASCII debug dump (full buffer) + +1. Feed a signal into the scope. +2. Press **Stop** to freeze the sample buffer. +3. Press **Menu**, then **Stop** so the Channel 1 Measurements menu is visible. +4. **Long-press Save** to initiate the debug data transmission. + +Transfers ~150–500 kB at 115200 baud. Delivers 60 000 raw ADC samples per +channel. Use `V/div` and `t/div` in the Measurement panel to set the scale. + +#### Method 2 — Screenshot binary packet (display pixels) + +1. Save a waveform on the device (short-press Save while viewing a signal). +2. Navigate to **Gallery** (Screenshots view) on the device. +3. Select the saved waveform — the device automatically transmits a **2048-byte + binary packet** containing the 300 display pixels plus all instrument settings. + +The app detects this packet by its size and decodes the embedded calibration +data to set `V/div`, `V/off`, `t/div` and **probe mode** (×1, ×10, ×100) +automatically. Probe and coupling info are shown in the Measurement panel's +Trigger section, and the probe multiplier is reflected in the displayed V/div +and V/off values. + +Use the `capture` CLI tool to capture and analyze screenshot packets from the +command line (see [CLI tools](#cli-tools) below). + +### Auto-reconnect + +If the USB cable is disconnected, the app automatically retries every 2 seconds. +Re-plug and the connection resumes without manual intervention. + +## Loading captures from files + +Click **Load Capture** and select a `.zwcap`, `.bin`, `.dat` or `.raw` file. +Files can contain one or many consecutive captures. + +When loading files that contain embedded instrument metadata (`.zwcap` files +or screenshot `.bin` files), the **V/div**, **V/off** and **t/div** settings +are **automatically populated** from the capture data. You can still adjust +them manually if needed. + +When switching between captures in the thumbnail strip, the scale settings +update automatically for each capture that has embedded metadata. + +## Viewing the graph + +* **Grid**: 10 × 8 divisions (oscilloscope style). +* **CH1** (yellow), **CH2** (cyan) — toggle visibility in the controls panel. +* **View mode** dropdown: Line, Dot, or Smooth (moving-average filter). + +## Scale configuration + +At the top of the **Measurement** panel you can set the real-world scale. +**V/div** and **V/off** are per-channel (CH1 / CH2 columns), while **t/div** +is shared (same timebase for both channels): + +| Field | Default | Meaning | +|----------|---------|-----------------------------------------------------------| +| V/div | 1.0 V | Volts per vertical division on the scope screen (per CH) | +| t/div | 1.0 ms | Time per horizontal division (shared) | +| V/off | 0.0 V | Voltage at graph center (offset, per CH) | + +CH1 and CH2 can have independent V/div and V/offset settings (just like +the real scope). The DSO3D12 screen has **8 vertical** and **12 horizontal** +divisions (AC mode). All voltage and time calculations, axis labels, and PNG +scales use the per-channel values. + +When a capture arrives via the **screenshot protocol**, V/div and V/offset +are automatically filled for each channel from the packet's embedded settings +metadata (CH1 and CH2 separately). + +## Measurement cursors + +In the right-hand **Measurement** panel: + +| Toggle | Slider | Readings | +|------------------|----------------------------|-----------------------------------| +| ☐ Cursor X (time) | Two knobs on horizontal range | Δt (ms), frequency (Hz) | +| ☐ Cursor Y (voltage) | Two knobs on vertical range | ΔV (volts) + absolute voltages per channel (CH1 and CH2) | + +**Drag the knob closest to your click** in the slider — or **click/drag +directly on the graph** to move the nearest cursor line. The shaded band +between cursors is drawn on the canvas in pink and updates in real time. +Y cursor voltages are computed independently for CH1 and CH2 using each +channel's V/div and V/offset settings. + +### Signal information + +When a capture is loaded the panel also shows: +* Voltage range for each channel (derived from each channel's V/div setting). +* Total time range (derived from t/div setting) + sample count. +* Per-channel statistics (sample count, min, max, average ADC). + +## Device cursors + +If a capture contains device-set cursor data (from the screenshot protocol), +the **Device X** / **Device Y** toggles in the controls panel become enabled. +These cursors are drawn as green lines and **cannot be moved** — they reflect +the device's own cursor state at the moment of capture. + +## Trigger & auto-measurements + +When a capture comes from the screenshot protocol, the measurement panel shows: +* **Trigger info**: mode (Auto/Normal/Single), edge, source, level, delay, + sample rate. +* **Auto-measurements table**: two columns (CH1, CH2) with Freq, PkPk, Avg, + RMS, Amp, ±Duty, ±T, T, Max, Min, Top, Base — all in physical units. + +## Thumbnails + +When multiple captures are loaded (from a multi-capture file or via serial), +a scrollable strip of buttons appears below the controls panel. Click any +thumbnail to switch the active capture. + +## Saving & exporting + +| Button | Action | +|-----------------|---------------------------------------------------------------| +| Save Capture | Writes all captures in `.zwcap` container format (CBOR). | +| Save PNG | Exports a 1200×600 px PNG image of the graph with grid+traces.| +| Export Data | Writes a CSV file with columns `index, ch1 [, ch2]`. | + +## Application Settings + +Click **Application Settings** to open the settings dialog: +* **Default serial port** — pre-filled on startup. +* **Baud rate** — default 115200 (matches the DSO3D12 debug mode). + +Settings are persisted to: +``` +~/Library/Application Support/dso3d12-gui/settings.json (macOS) +~/.config/dso3d12-gui/settings.json (Linux) +%APPDATA%\dso3d12-gui\settings.json (Windows) +``` + +## Logs + +Activity is logged to stderr (console) by default. File logging to +`/dso3d12-gui/dso3d12-gui.log` can be enabled by setting +`DSO_LOG_FILE=1`. Rotation at ~1 MB. Increase verbosity: + +```bash +RUST_LOG=debug dso3d12-gui +# Or with file logging: +DSO_LOG_FILE=1 RUST_LOG=debug dso3d12-gui +``` + +--- + +## CLI tools + +### `capture` — screenshot packet capture + +```bash +# List available serial ports: +capture -l + +# Wait for a screenshot packet on the default port and print a verbose report: +capture + +# Specify port, save raw binary and timeout: +capture --port /dev/tty.usbserial-1110 --save capture.bin --timeout 60 + +# Parse as a different device variant: +capture --device dso2512g-v2 +``` + +**Steps:** +1. Connect the DSO3D12 via USB. +2. Run `capture` (it will wait for the device). +3. On the scope: **Gallery → select a saved waveform**. +4. The device transmits a 2048-byte packet; `capture` parses and prints the + full verbose report including calibrated waveform points and auto-measurements. + +If `--save path.bin` is specified the raw packet is written to disk for later +re-analysis or use with custom tooling. + +## Troubleshooting + +| Symptom | Fix | +|--------------------------------------|---------------------------------------------------------| +| Port not listed | Check USB connection; try `ls /dev/tty.*` in terminal. | +| "Cannot open port" error | Another process may hold the port; close other apps. | +| Listening but no capture appears | Ensure you long-press Save on the device (not short). | +| Graph empty after load | File may not be DSO3D12 format; check status bar error. | +| Cursors don't appear | Enable them in the Measurement panel checkboxes. | +| Device cursor toggles greyed out | Capture has no device-cursor metadata (normal today). | + +## Voltage & time reference + +* Voltage range = V/div × 8 divs (centered at 0 → ±4 × V/div). +* Time range = t/div × 12 divs. +* Set V/div and t/div in the Measurement panel to match your scope settings. +* These defaults will be refined when device metadata parsing is extended. diff --git a/dso-parser/Cargo.toml b/dso-parser/Cargo.toml new file mode 100644 index 0000000..288e564 --- /dev/null +++ b/dso-parser/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dso-parser" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Parser for ZeeWeii oscilloscope data formats (debug dumps and screenshot packets)" + +[dependencies] +thiserror = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +ciborium = "0.2" + +[dev-dependencies] diff --git a/dso-parser/src/container.rs b/dso-parser/src/container.rs new file mode 100644 index 0000000..839218a --- /dev/null +++ b/dso-parser/src/container.rs @@ -0,0 +1,459 @@ +//! `.zwcap` container format — serializes captures (both debug dumps and +//! screenshot packets) into a single CBOR-encoded binary file. +//! +//! ## File layout +//! ```text +//! Bytes 0–7 : Magic header b"ZWCAP\x00\x01\x00" (version 1) +//! Bytes 8.. : CBOR-encoded Vec +//! ``` + +use chrono::{DateTime, Local}; +use serde::{Deserialize, Serialize}; + +/// Magic header for `.zwcap` files. +pub const ZWCAP_MAGIC: &[u8; 8] = b"ZWCAP\x00\x01\x00"; + +/// Top-level capture record stored in the container. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureRecord { + /// Schema version. + /// - **1**: legacy — CH1 from ASCII dumps stored in raw-ADC convention + /// (high value = top of screen). + /// - **2**: current — all channels use screen-coordinate convention + /// (low value = top of screen = positive voltage). + pub schema_version: u16, + /// How the capture was originally acquired. + pub source_format: SourceFormat, + /// Device model string (e.g. "DSO3D12"). + pub device_model: Option, + /// ISO 8601 timestamp with timezone when capture was received. + pub captured_at: Option, + /// Raw binary payload (the original bytes as received). + pub raw_payload: Vec, + /// Oscilloscope state at time of capture (from screenshot metadata). + pub scope_state: Option, + /// Decoded waveforms ready for display. + pub waveforms: Vec, +} + +/// How the capture was originally obtained. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceFormat { + /// ASCII debug dump (long-press Save, 60000 samples/channel). + HiddenAsciiDump, + /// Binary 2048-byte screenshot packet (Gallery → select waveform). + ScreenshotBinary, +} + +/// Oscilloscope state metadata (from screenshot packet settings section). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeState { + pub timebase_label: String, + pub timebase_ns_per_div: u64, + pub sample_rate_hz: f64, + pub buffer_depth: i64, + pub trigger_source: String, + pub trigger_mode: String, + pub trigger_edge: String, + pub trigger_level_v: f64, + pub trigger_delay_s: f64, + pub ch1: Option, + pub ch2: Option, + pub cursors_x_enable: bool, + pub cursors_y_enable: bool, + /// Cursor X1 position as fraction 0.0–1.0 of graph width. + pub cursor_x1_frac: f64, + /// Cursor X2 position as fraction 0.0–1.0 of graph width. + pub cursor_x2_frac: f64, + /// Cursor Y1 position as fraction 0.0–1.0 of graph height. + pub cursor_y1_frac: f64, + /// Cursor Y2 position as fraction 0.0–1.0 of graph height. + pub cursor_y2_frac: f64, + /// Active channel (0 = CH1, 1 = CH2). Used for device cursor voltage. + #[serde(default)] + pub active_channel: u8, +} + +/// Per-channel state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelState { + pub vdiv_v: f64, + pub coupling: String, + pub probe: String, + pub v_offset_v: f64, + pub measurements: Option, +} + +/// Auto-measurement values in physical units. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelMeasurements { + pub freq_hz: f64, + pub period_s: f64, + pub pk_pk_v: f64, + pub amplitude_v: f64, + pub max_v: f64, + pub min_v: f64, + pub top_v: f64, + pub base_v: f64, + pub avg_v: f64, + pub rms_v: f64, + pub pos_width_s: f64, + pub neg_width_s: f64, + pub pos_duty_pct: f64, + pub neg_duty_pct: f64, +} + +/// A waveform stored in the capture. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Waveform { + /// Full-buffer raw ADC samples (from debug dump). + RawSamples { + channel: u8, + /// ADC values 0–255 (stored as i16 for future headroom). + counts: Vec, + /// Seconds per sample (if known from metadata). + sample_interval_s: Option, + /// Volts per ADC count (if calibrated). + volts_per_count: Option, + }, + /// Display-resolution envelope (from screenshot packet). + DisplayEnvelope { + channel: u8, + /// Minimum ADC value per display pixel. + min_counts: Vec, + /// Maximum ADC value per display pixel. + max_counts: Vec, + /// Seconds per pixel. + time_per_pixel_s: Option, + /// µV per pixel (volt_scale_uv / 25). + volts_per_pixel: Option, + /// Zero-volt reference offset in pixels. + zero_pixel: Option, + }, +} + +// ── Serialization ────────────────────────────────────────────────────────── + +/// Serialize a list of capture records to the `.zwcap` binary format. +pub fn serialize_zwcap(records: &[CaptureRecord]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(ZWCAP_MAGIC); + ciborium::into_writer(records, &mut buf).expect("CBOR serialize should not fail"); + buf +} + +/// Deserialize a `.zwcap` file. Returns an error if the magic header is wrong +/// or CBOR decoding fails. +pub fn deserialize_zwcap(data: &[u8]) -> Result, String> { + if data.len() < 8 || &data[..8] != ZWCAP_MAGIC { + return Err("Not a .zwcap file (bad magic header)".to_string()); + } + ciborium::from_reader(&data[8..]).map_err(|e| format!("CBOR decode error: {e}")) +} + +/// Returns true if the buffer starts with the `.zwcap` magic header. +pub fn is_zwcap(data: &[u8]) -> bool { + data.len() >= 8 && &data[..8] == ZWCAP_MAGIC +} + +// ── Conversion helpers ───────────────────────────────────────────────────── + +use crate::screenshot::{self, ScreenshotPacket}; +use crate::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture, PIXELS_PER_DIV}; + +/// Convert an ASCII debug dump `Capture` into a `CaptureRecord`. +pub fn capture_to_record( + cap: &Capture, + timestamp: DateTime, + raw_bytes: Option<&[u8]>, +) -> CaptureRecord { + let mut waveforms = vec![Waveform::RawSamples { + channel: 1, + counts: cap.ch1.iter().map(|&b| b as i16).collect(), + sample_interval_s: None, + volts_per_count: None, + }]; + if let Some(ch2) = &cap.ch2 { + waveforms.push(Waveform::RawSamples { + channel: 2, + counts: ch2.iter().map(|&b| b as i16).collect(), + sample_interval_s: None, + volts_per_count: None, + }); + } + CaptureRecord { + schema_version: 2, + source_format: SourceFormat::HiddenAsciiDump, + device_model: Some("DSO3D12".to_string()), + captured_at: Some(timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()), + raw_payload: raw_bytes.map(|b| b.to_vec()).unwrap_or_default(), + scope_state: None, + waveforms, + } +} + +/// Convert a parsed screenshot packet into a `CaptureRecord`. +pub fn screenshot_to_record( + pkt: &ScreenshotPacket, + timestamp: DateTime, + raw_bytes: &[u8], +) -> CaptureRecord { + let s = &pkt.settings; + let probe_mult = |p: u8| 10f64.powi(p as i32); + + let ch1_state = ChannelState { + vdiv_v: s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe_mult(s.ch1.probe_mode), + coupling: match s.ch1.coupling { 0 => "DC", _ => "AC" }.to_string(), + probe: match s.ch1.probe_mode { 0 => "×1", 1 => "×10", _ => "×100" }.to_string(), + v_offset_v: s.ch1.zero_volt_uv as f64 / 1_000_000.0 * probe_mult(s.ch1.probe_mode), + measurements: pkt.ch1_meas.as_ref().map(|m| measurements_to_state(m, s.ch1.probe_mode)), + }; + + let ch2_state = if s.ch2.enabled { + Some(ChannelState { + vdiv_v: s.ch2.volt_scale_uv as f64 / 1_000_000.0 * probe_mult(s.ch2.probe_mode), + coupling: match s.ch2.coupling { 0 => "DC", _ => "AC" }.to_string(), + probe: match s.ch2.probe_mode { 0 => "×1", 1 => "×10", _ => "×100" }.to_string(), + v_offset_v: s.ch2.zero_volt_uv as f64 / 1_000_000.0 * probe_mult(s.ch2.probe_mode), + measurements: pkt.ch2_meas.as_ref().map(|m| measurements_to_state(m, s.ch2.probe_mode)), + }) + } else { + None + }; + + // Cursor fractions: device uses pixel offsets from center (150 for X, 128 for Y) + let cx1 = (s.cursor_x1 as f64 + 150.0) / 300.0; + let cx2 = (s.cursor_x2 as f64 + 150.0) / 300.0; + let cy1 = (s.cursor_y1 as f64 + ADC_VALUE_MID as f64) / 256.0; + let cy2 = (s.cursor_y2 as f64 + ADC_VALUE_MID as f64) / 256.0; + + let scope_state = ScopeState { + timebase_label: s.timebase_label().to_string(), + timebase_ns_per_div: s.timebase_ns_per_div, + sample_rate_hz: s.sample_rate_meas_hz, + buffer_depth: s.buffer_depth, + trigger_source: match s.trigger_source { 0 => "CH1", _ => "CH2" }.to_string(), + trigger_mode: match s.trigger_mode { 0 => "Auto", _ => "Normal" }.to_string(), + trigger_edge: match s.trigger_edge { 0 => "Rise", _ => "Fall" }.to_string(), + trigger_level_v: if s.trigger_source == 0 { + s.ch1.trigger_level_uv as f64 / 1_000_000.0 * probe_mult(s.ch1.probe_mode) + } else { + s.ch2.trigger_level_uv as f64 / 1_000_000.0 * probe_mult(s.ch2.probe_mode) + }, + trigger_delay_s: s.horiz_trigger_delay_ps as f64 / 1e12, + ch1: Some(ch1_state), + ch2: ch2_state, + cursors_x_enable: s.cursors_x_enable, + cursors_y_enable: s.cursors_y_enable, + cursor_x1_frac: cx1.clamp(0.0, 1.0), + cursor_x2_frac: cx2.clamp(0.0, 1.0), + cursor_y1_frac: cy1.clamp(0.0, 1.0), + cursor_y2_frac: cy2.clamp(0.0, 1.0), + active_channel: s.active_channel, + }; + + let mut waveforms = vec![Waveform::DisplayEnvelope { + channel: 1, + min_counts: pkt.ch1_min.to_vec(), + max_counts: pkt.ch1_max.to_vec(), + time_per_pixel_s: Some(s.time_per_pixel_s()), + volts_per_pixel: Some(s.ch1.volt_scale_uv as f64 / PIXELS_PER_DIV), + zero_pixel: Some(s.ch1.zero_volt_pixels as i16), + }]; + if let (Some(mn), Some(mx)) = (&pkt.ch2_min, &pkt.ch2_max) { + waveforms.push(Waveform::DisplayEnvelope { + channel: 2, + min_counts: mn.to_vec(), + max_counts: mx.to_vec(), + time_per_pixel_s: Some(s.time_per_pixel_s()), + volts_per_pixel: Some(s.ch2.volt_scale_uv as f64 / PIXELS_PER_DIV), + zero_pixel: Some(s.ch2.zero_volt_pixels as i16), + }); + } + + CaptureRecord { + schema_version: 1, + source_format: SourceFormat::ScreenshotBinary, + device_model: Some(pkt.device.to_string()), + captured_at: Some(timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()), + raw_payload: raw_bytes.to_vec(), + scope_state: Some(scope_state), + waveforms, + } +} + +/// Maximum plausible voltage (100 kV) — device leaves uninitialized fields +/// with garbage that can reach hundreds of megavolts. +const MEAS_MAX_V: f64 = 100_000.0; +/// Maximum plausible pulse/period width in seconds. +/// The DSO3D12's widest timebase is 10 s/div × 12 divs = 120 s. +const MEAS_MAX_T: f64 = 200.0; + +/// Return `v` unchanged, or `NaN` if the value looks like uninitialised data. +#[inline] +fn plausible_v(v: f64) -> f64 { + if v.is_finite() && v.abs() <= MEAS_MAX_V { v } else { f64::NAN } +} + +/// Return `v` unchanged, or `NaN` if outside the device's possible time range. +#[inline] +fn plausible_t(t: f64) -> f64 { + if t.is_finite() && (0.0..=MEAS_MAX_T).contains(&t) { t } else { f64::NAN } +} + +fn measurements_to_state(m: &screenshot::Measurements, probe: u8) -> ChannelMeasurements { + let mult = 10f64.powi(probe as i32); + let uv = |raw: i64| plausible_v(raw as f64 / 1_000_000.0 * mult); + ChannelMeasurements { + freq_hz: { let v = m.freq_hz(); if v.is_finite() && v >= 0.0 { v } else { f64::NAN } }, + period_s: plausible_t(m.period_s()), + pk_pk_v: uv(m.pk_pk_uv), + amplitude_v: uv(m.amplitude_uv), + max_v: uv(m.max_uv), + min_v: uv(m.min_uv), + top_v: uv(m.top_uv), + base_v: uv(m.base_uv), + avg_v: uv(m.avg_uv), + rms_v: uv(m.rms_uv), + pos_width_s: plausible_t(m.pos_width_ps as f64 / 1e12), + neg_width_s: plausible_t(m.neg_width_ps as f64 / 1e12), + pos_duty_pct: { let v = m.pos_duty_pct(); if v.is_finite() && (0.0..=100.0).contains(&v) { v } else { f64::NAN } }, + neg_duty_pct: { let v = m.neg_duty_tenth_pct as f64 / 10.0; if v.is_finite() && (0.0..=100.0).contains(&v) { v } else { f64::NAN } }, + } +} + +/// Convert a `CaptureRecord` back to the legacy `Capture` struct for display. +/// This allows the GUI to use a unified rendering pipeline. +/// +/// Schema version 1 (legacy) stored CH1 from ASCII dumps in raw-ADC convention +/// (high value = top of screen). Version 2+ stores both channels in screen- +/// coordinate convention (low value = top of screen = positive voltage). +pub fn record_to_capture(rec: &CaptureRecord) -> Capture { + let old_ascii = rec.schema_version <= 1 + && rec.source_format == SourceFormat::HiddenAsciiDump; + match rec.source_format { + SourceFormat::HiddenAsciiDump => { + let mut ch1 = Vec::new(); + let mut ch2: Option> = None; + for w in &rec.waveforms { + match w { + Waveform::RawSamples { channel: 1, counts, .. } => { + let mut raw: Vec = counts.iter().map(|&c| c.clamp(0, ADC_VALUE_MAX as i16) as u8).collect(); + // Old-format: CH1 stored in raw-ADC convention → invert to match. + if old_ascii { + raw.iter_mut().for_each(|v| *v = ADC_VALUE_MAX - *v); + } + ch1 = raw; + } + Waveform::RawSamples { channel: 2, counts, .. } => { + ch2 = Some(counts.iter().map(|&c| c.clamp(0, ADC_VALUE_MAX as i16) as u8).collect()); + } + _ => {} + } + } + Capture { ch1, ch2 } + } + SourceFormat::ScreenshotBinary => { + // For screenshot packets, construct a Capture from the envelope average. + let mut ch1 = Vec::new(); + let mut ch2: Option> = None; + for w in &rec.waveforms { + match w { + Waveform::DisplayEnvelope { channel: 1, min_counts, max_counts, .. } => { + ch1 = min_counts.iter().zip(max_counts.iter()) + .map(|(&mn, &mx)| ((mn as u16 + mx as u16) / 2) as u8) + .collect(); + } + Waveform::DisplayEnvelope { channel: 2, min_counts, max_counts, .. } => { + ch2 = Some(min_counts.iter().zip(max_counts.iter()) + .map(|(&mn, &mx)| ((mn as u16 + mx as u16) / 2) as u8) + .collect()); + } + _ => {} + } + } + Capture { ch1, ch2 } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_zwcap() { + let rec = CaptureRecord { + schema_version: 1, + source_format: SourceFormat::HiddenAsciiDump, + device_model: Some("DSO3D12".to_string()), + captured_at: Some("2025-05-31T12:00:00+03:00".to_string()), + raw_payload: vec![1, 2, 3], + scope_state: None, + waveforms: vec![Waveform::RawSamples { + channel: 1, + counts: vec![100, 110, 120], + sample_interval_s: None, + volts_per_count: None, + }], + }; + let data = serialize_zwcap(std::slice::from_ref(&rec)); + assert!(is_zwcap(&data)); + let decoded = deserialize_zwcap(&data).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].device_model, rec.device_model); + assert_eq!(decoded[0].raw_payload, rec.raw_payload); + } + + #[test] + fn record_to_capture_v1_backward_compat() { + // Old-format (schema_version == 1): CH1 stored in raw-ADC convention, + // CH2 already in screen-coordinate convention. + let rec = CaptureRecord { + schema_version: 1, + source_format: SourceFormat::HiddenAsciiDump, + device_model: None, + captured_at: None, + raw_payload: vec![], + scope_state: None, + waveforms: vec![ + Waveform::RawSamples { channel: 1, counts: vec![10, 20, 30], sample_interval_s: None, volts_per_count: None }, + Waveform::RawSamples { channel: 2, counts: vec![40, 50, 60], sample_interval_s: None, volts_per_count: None }, + ], + }; + let cap = record_to_capture(&rec); + // CH1 inverted for backward compat: 255-10=245, 255-20=235, 255-30=225 + assert_eq!(cap.ch1, vec![245, 235, 225]); + // CH2 was already in screen-coordinate convention + assert_eq!(cap.ch2, Some(vec![40, 50, 60])); + } + + #[test] + fn record_to_capture_v2_roundtrip() { + // New format (schema_version == 2): both channels in screen-coordinate + // convention — no transformation needed on read-back. + let rec = CaptureRecord { + schema_version: 2, + source_format: SourceFormat::HiddenAsciiDump, + device_model: None, + captured_at: None, + raw_payload: vec![], + scope_state: None, + waveforms: vec![ + Waveform::RawSamples { channel: 1, counts: vec![245, 235, 225], sample_interval_s: None, volts_per_count: None }, + Waveform::RawSamples { channel: 2, counts: vec![215, 205, 195], sample_interval_s: None, volts_per_count: None }, + ], + }; + let cap = record_to_capture(&rec); + assert_eq!(cap.ch1, vec![245, 235, 225]); + assert_eq!(cap.ch2, Some(vec![215, 205, 195])); + } + + #[test] + fn is_zwcap_detection() { + assert!(!is_zwcap(b"not a zwcap file")); + assert!(!is_zwcap(b"")); + let data = serialize_zwcap(&[]); + assert!(is_zwcap(&data)); + } +} diff --git a/dso-parser/src/lib.rs b/dso-parser/src/lib.rs new file mode 100644 index 0000000..43dfe4a --- /dev/null +++ b/dso-parser/src/lib.rs @@ -0,0 +1,469 @@ +//! Parser for ZeeWeii DSO3D12 oscilloscope raw debug dumps. +//! +//! Format (as transmitted by the device after the "Long-press Save" debug +//! dump action, captured directly from the serial port): +//! +//! ```text +//! --------CH1--------\r\n +//! NNN \0NNN \0NNN \0... (Buffer Depth samples; default 60000 per channel) +//! [optional] +//! --------CH2--------\r\n +//! NNN \0NNN \0... +//! ``` +//! +//! Each sample is an ASCII decimal integer 0..=255 (one byte of ADC data), +//! followed by a single space (`0x20`) and a NUL byte (`0x00`). + +use thiserror::Error; + +pub mod screenshot; +pub use screenshot::{ + parse_screenshot, parse_screenshot_as, is_screenshot_packet, + DeviceVariant, ScreenshotPacket, OscSettings, ChannelSettings, + Measurements, PixelPoint, + format_uv, format_ps, format_hz, format_sample_rate, +}; + +pub mod container; +pub use container::{ + CaptureRecord, SourceFormat, ScopeState, ChannelState, ChannelMeasurements, + Waveform, serialize_zwcap, deserialize_zwcap, is_zwcap, + capture_to_record, screenshot_to_record, record_to_capture, + ZWCAP_MAGIC, +}; + +/// Default total buffer depth in samples that the DSO3D12 transmits per +/// channel for a complete debug dump. +pub const DEFAULT_BUFFER_DEPTH: usize = 60_000; + +/// ASCII header marking the start of channel 1 samples. +pub const CH1_MARKER: &[u8] = b"--------CH1--------"; + +/// ASCII header marking the start of channel 2 samples. +pub const CH2_MARKER: &[u8] = b"--------CH2--------"; + +// ── ADC & display constants ─────────────────────────────────────────────── + +/// Maximum ADC sample value (8-bit unsigned). +pub const ADC_VALUE_MAX: u8 = 255; + +/// ADC midpoint (centre of the 0..=255 range, zero reference in pixel_to_volts). +pub const ADC_VALUE_MID: u8 = 128; + +/// Device display vertical pixels per division (200 px ÷ 8 div = 25 px/div). +pub const PIXELS_PER_DIV: f64 = 25.0; // corresponds to snapshots + +// ── Capture struct ──────────────────────────────────────────────────────── +/// +/// Sample values are raw 8-bit ADC counts (0..=255). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Capture { + /// Channel 1 samples (always present). + pub ch1: Vec, + /// Channel 2 samples (present only if the dump contained `--CH2--`). + pub ch2: Option>, +} + +impl Capture { + /// Returns the number of samples in the longest channel. + pub fn sample_count(&self) -> usize { + let n1 = self.ch1.len(); + let n2 = self.ch2.as_ref().map(|v| v.len()).unwrap_or(0); + n1.max(n2) + } +} + +/// Errors returned by the parser. +#[derive(Debug, Error)] +pub enum ParseError { + #[error("no CH1 marker found in input")] + MissingCh1Marker, + + #[error("malformed sample at byte offset {offset}: {reason}")] + MalformedSample { offset: usize, reason: &'static str }, + + #[error("buffer too short: got {got} bytes, expected at least {expected}")] + TooShort { got: usize, expected: usize }, +} + +/// Parse every capture contained in `data`. +/// +/// A new capture starts at every occurrence of [`CH1_MARKER`]. The parser is +/// tolerant: it will skip over any non-sample bytes (extra whitespace, status +/// strings printed by the device between captures, etc.) until it sees the +/// next ASCII digit or marker. +pub fn parse_captures(data: &[u8]) -> Result, ParseError> { + let mut captures = Vec::new(); + let mut cursor = 0; + + while let Some(start) = find_subslice(&data[cursor..], CH1_MARKER) { + let abs = cursor + start; + let ch1_body = skip_marker_eol(data, abs + CH1_MARKER.len()); + + // CH1 samples end either at the CH2 marker or at the next CH1 marker + // (next capture) or at end of file. + let ch1_end = find_next_boundary(data, ch1_body); + // Invert CH1 so it uses the same screen-coordinate convention as + // screenshot data (low value = top of screen = positive voltage), + // matching what was already done for CH2 below. + let ch1: Vec = decode_ascii_samples(&data[ch1_body..ch1_end]) + .into_iter().map(|v| ADC_VALUE_MAX - v).collect(); + + // Check whether the next boundary is a CH2 within the same capture. + let mut next_cursor = ch1_end; + let ch2 = if data[ch1_end..].starts_with(CH2_MARKER) { + let ch2_body = skip_marker_eol(data, ch1_end + CH2_MARKER.len()); + let ch2_end = find_next_boundary(data, ch2_body); + // CH2 uses the same screen-coordinate convention as CH1 and screenshot data: + // low value = near top of screen = positive voltage. + let samples: Vec = decode_ascii_samples(&data[ch2_body..ch2_end]) + .into_iter().map(|v| ADC_VALUE_MAX - v).collect(); + next_cursor = ch2_end; + Some(samples) + } else { + None + }; + + captures.push(Capture { ch1, ch2 }); + cursor = next_cursor; + } + + if captures.is_empty() { + return Err(ParseError::MissingCh1Marker); + } + Ok(captures) +} + +/// Parse exactly one capture: the first `--------CH1--------` block found in +/// `data` plus its optional companion `--------CH2--------` block. +pub fn parse_first(data: &[u8]) -> Result { + parse_captures(data).map(|mut v| v.remove(0)) +} + +/// Returns `true` if `data` looks like (or could become) the start of a valid +/// DSO3D12 raw debug dump — used by the live-serial state machine to decide +/// whether to keep buffering or discard a noise stream. +pub fn looks_like_dump_start(data: &[u8]) -> bool { + if data.is_empty() { + return false; + } + // Accept any prefix of the marker as a partial header. + let limit = data.len().min(CH1_MARKER.len()); + CH1_MARKER.starts_with(&data[..limit]) +} + +/// Convert a raw ADC byte (0..=255) into a normalized signed value in `[-1, 1]`, +/// matching the way the device places "screen center" at code 128. +/// +/// **Note:** This function expects **raw ADC values** from the device, not the +/// screen-coordinate convention used by [`Capture`] (where 0 = top of screen). +/// `Capture.ch1` / `Capture.ch2` values are already in screen-coordinate +/// convention and should NOT be passed here directly. +/// +/// This is the simplest scaling we can apply without device-side metadata +/// (volt-scale, probe mode, zero offset). Callers that have access to the +/// settings buffer can substitute their own conversion. +pub fn adc_to_normalized(byte: u8) -> f32 { + // Match ZeeTweak: y_screen = ADC_VALUE_MAX - raw; center = ADC_VALUE_MID. + let centered = (ADC_VALUE_MAX as i16 - byte as i16) - ADC_VALUE_MID as i16; + centered as f32 / ADC_VALUE_MID as f32 +} + +// ---------- internals ---------- + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|w| w == needle) +} + +/// Advance past `\r`, `\n`, or `\r\n` line endings immediately after a marker. +fn skip_marker_eol(data: &[u8], mut i: usize) -> usize { + if i < data.len() && data[i] == b'\r' { + i += 1; + } + if i < data.len() && data[i] == b'\n' { + i += 1; + } + i +} + +/// Find the next CH1 or CH2 marker in `data` starting at `from`, or end of +/// data if none. +fn find_next_boundary(data: &[u8], from: usize) -> usize { + let mut best = data.len(); + if let Some(p) = find_subslice(&data[from..], CH1_MARKER) { + best = best.min(from + p); + } + if let Some(p) = find_subslice(&data[from..], CH2_MARKER) { + best = best.min(from + p); + } + best +} + +/// Decode a sequence of ASCII samples of the form `"NN \0NNN \0..."` into +/// byte values. Malformed tokens are silently skipped; this keeps the parser +/// robust against the occasional dropped serial byte. +fn decode_ascii_samples(body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(DEFAULT_BUFFER_DEPTH); + let mut acc: u32 = 0; + let mut have_digit = false; + + for &b in body { + match b { + b'0'..=b'9' => { + acc = acc.saturating_mul(10).saturating_add((b - b'0') as u32); + have_digit = true; + } + // Terminators that complete a token. + b' ' | 0x00 | b'\r' | b'\n' | b'\t' => { + if have_digit { + if acc <= ADC_VALUE_MAX as u32 { + out.push(acc as u8); + } + acc = 0; + have_digit = false; + } + } + _ => { + // Unknown byte: drop the in-flight token to avoid pollution. + acc = 0; + have_digit = false; + } + } + } + if have_digit && acc <= ADC_VALUE_MAX as u32 { + out.push(acc as u8); + } + out +} + +// ── Metadata (timestamps appended after capture data) ────────────────────── + +use chrono::{DateTime, Local, TimeZone}; + +/// Metadata marker appended after all capture data. ZeeTweak and our parser +/// both ignore this because it doesn't start with `--------CH1--------`. +pub const METADATA_MARKER: &[u8] = b"\n---DSO3D12-META---\n"; + +/// A capture paired with a timestamp indicating when it was received. +#[derive(Debug, Clone)] +pub struct CaptureEntry { + pub capture: Capture, + pub timestamp: DateTime, +} + +/// Parse the metadata section (timestamps) from file bytes. +/// Returns `None` if no metadata marker is found or if parsing fails. +pub fn parse_metadata(data: &[u8]) -> Option>> { + let marker = METADATA_MARKER; + // Find the metadata marker (search from end for efficiency) + let pos = data.windows(marker.len()).rposition(|w| w == marker)?; + let json_start = pos + marker.len(); + let json_bytes = &data[json_start..]; + let json_end = json_bytes.iter().position(|&b| b == b'\n').unwrap_or(json_bytes.len()); + let json_str = std::str::from_utf8(&json_bytes[..json_end]).ok()?; + let ts_strings: Vec = serde_json::from_str(json_str).ok()?; + let timestamps: Vec> = ts_strings + .iter() + .filter_map(|s| { + chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%:z") + .ok() + .map(|dt| Local.from_utc_datetime(&dt.naive_utc())) + }) + .collect(); + if timestamps.is_empty() { + None + } else { + Some(timestamps) + } +} + +/// Load captures from file bytes with timestamps. +/// Parses both the raw capture data and optional metadata section. +pub fn load_captures_with_metadata(data: &[u8]) -> Result, ParseError> { + let captures = parse_captures(data)?; + let timestamps = parse_metadata(data); + let now = Local::now(); + let entries = captures + .into_iter() + .enumerate() + .map(|(i, capture)| { + let timestamp = timestamps + .as_ref() + .and_then(|ts| ts.get(i).copied()) + .unwrap_or(now); + CaptureEntry { capture, timestamp } + }) + .collect(); + Ok(entries) +} + +/// Serialize captures with metadata (timestamps) into the DSO3D12 file format. +/// The output is compatible with ZeeTweak and other parsers — metadata is appended +/// at the end after a marker they won't recognize as a channel header. +pub fn serialize_captures(entries: &[CaptureEntry]) -> Vec { + use std::io::Write; + let mut buf = Vec::new(); + + for entry in entries { + // Write CH1 + buf.extend_from_slice(b"--------CH1--------\r\n"); + for (i, &sample) in entry.capture.ch1.iter().enumerate() { + if i > 0 { + buf.extend_from_slice(b" \0"); + } + write!(buf, "{sample}").unwrap(); + } + buf.extend_from_slice(b" \0"); + + // Write CH2 if present + if let Some(ref ch2) = entry.capture.ch2 { + buf.extend_from_slice(b"--------CH2--------\r\n"); + for (i, &sample) in ch2.iter().enumerate() { + if i > 0 { + buf.extend_from_slice(b" \0"); + } + write!(buf, "{sample}").unwrap(); + } + buf.extend_from_slice(b" \0"); + } + } + + // Append metadata (timestamps) + buf.extend_from_slice(METADATA_MARKER); + let timestamps: Vec = entries + .iter() + .map(|e| e.timestamp.format("%Y-%m-%dT%H:%M:%S%:z").to_string()) + .collect(); + if let Ok(meta_json) = serde_json::to_string(×tamps) { + buf.extend_from_slice(meta_json.as_bytes()); + } + buf.push(b'\n'); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_dump(ch1: &[u8], ch2: Option<&[u8]>) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(CH1_MARKER); + out.extend_from_slice(b"\r\n"); + for s in ch1 { + out.extend_from_slice(format!("{} ", s).as_bytes()); + out.push(0); + } + if let Some(c2) = ch2 { + out.extend_from_slice(CH2_MARKER); + out.extend_from_slice(b"\r\n"); + for s in c2 { + out.extend_from_slice(format!("{} ", s).as_bytes()); + out.push(0); + } + } + out + } + + #[test] + fn parses_single_ch1_capture() { + let dump = make_dump(&[137, 138, 137, 139], None); + let cap = parse_first(&dump).unwrap(); + // Both channels now use screen-coordinate convention (low = top). + assert_eq!(cap.ch1, vec![118, 117, 118, 116]); + assert!(cap.ch2.is_none()); + } + + #[test] + fn parses_dual_channel_capture() { + let dump = make_dump(&[10, 20, 30], Some(&[40, 50, 60])); + let cap = parse_first(&dump).unwrap(); + // Both channels now use screen-coordinate convention (low = top). + assert_eq!(cap.ch1, vec![245, 235, 225]); + assert_eq!(cap.ch2.as_deref(), Some(&[215u8, 205, 195][..])); + } + + #[test] + fn parses_multiple_consecutive_captures() { + let mut data = make_dump(&[1, 2, 3], None); + data.extend_from_slice(&make_dump(&[4, 5, 6], Some(&[7, 8]))); + let caps = parse_captures(&data).unwrap(); + assert_eq!(caps.len(), 2); + // Both channels use screen-coordinate convention (low = top). + assert_eq!(caps[0].ch1, vec![254, 253, 252]); + assert_eq!(caps[1].ch1, vec![251, 250, 249]); + assert_eq!(caps[1].ch2.as_deref(), Some(&[248u8, 247][..])); + } + + #[test] + fn missing_marker_is_error() { + let err = parse_captures(b"no markers here").unwrap_err(); + matches!(err, ParseError::MissingCh1Marker); + } + + #[test] + fn skips_malformed_tokens() { + let mut data = Vec::new(); + data.extend_from_slice(CH1_MARKER); + data.extend_from_slice(b"\r\n"); + data.extend_from_slice(b"137 \x00garbage \x00200 \x00"); + let cap = parse_first(&data).unwrap(); + // Inverted: 255-137=118, 255-200=55 + assert_eq!(cap.ch1, vec![118, 55]); + } + + #[test] + fn clamps_values_above_255() { + let mut data = Vec::new(); + data.extend_from_slice(CH1_MARKER); + data.extend_from_slice(b"\r\n"); + data.extend_from_slice(b"300 \x00128 \x00"); + let cap = parse_first(&data).unwrap(); + // 300 is out-of-range for u8 and gets dropped. 128 inverted = 127. + assert_eq!(cap.ch1, vec![127]); + } + + #[test] + fn adc_normalization_centers_at_zero() { + assert!((adc_to_normalized(127) - (0.0_f32)).abs() < 0.01); + assert!(adc_to_normalized(0) > 0.9); + assert!(adc_to_normalized(255) < -0.9); + } + + #[test] + fn looks_like_dump_start_accepts_prefix() { + assert!(looks_like_dump_start(b"--")); + assert!(looks_like_dump_start(b"--------CH1")); + assert!(looks_like_dump_start(b"--------CH1--------")); + assert!(!looks_like_dump_start(b"hello")); + assert!(!looks_like_dump_start(b"")); + } + + #[test] + fn sample_count_is_max_of_channels() { + let c = Capture { + ch1: vec![0; 100], + ch2: Some(vec![0; 250]), + }; + assert_eq!(c.sample_count(), 250); + } + + #[test] + fn parses_real_example_file_first_capture() { + // Smoke-test: if the example file is present, make sure we get + // ~60000 samples in CH1. + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../scripts/captures/dso3d12_raw_115200_20260530_183115.bin"); + if !path.exists() { + return; + } + let bytes = std::fs::read(&path).expect("read example"); + let cap = parse_first(&bytes).expect("parse"); + assert_eq!(cap.ch1.len(), DEFAULT_BUFFER_DEPTH); + assert!(cap.ch2.is_none()); + } +} diff --git a/dso-parser/src/screenshot.rs b/dso-parser/src/screenshot.rs new file mode 100644 index 0000000..7342b06 --- /dev/null +++ b/dso-parser/src/screenshot.rs @@ -0,0 +1,950 @@ +//! Parser for the ZeeWeii "screenshot" binary protocol. +//! +//! Based on https://github.com/taligentx/ZeeTweak/blob/main/zeetweak.py +//! +//! When the user opens a saved waveform in the device's Screenshot/Gallery view +//! (or presses Save while live), the device transmits a **2048-byte binary packet** +//! at 115200 baud over the USB-serial port. +//! +//! ## Packet layout +//! ```text +//! Bytes 0 – 299 : CH1 min ADC values (300 × u8, inverted: real = 255 − raw) +//! Bytes 300 – 599 : CH1 max ADC values (300 × u8, inverted) +//! Bytes 600 – 899 : CH2 min ADC values (300 × u8, inverted) +//! Bytes 900 – 1199 : CH2 max ADC values (300 × u8, inverted) +//! Bytes 1200 – 1539 : Settings / state (DATA_DSO3D12 map, 340 bytes) +//! Bytes 1540 – 1639 : CH1 auto-measurements (100 bytes = 12×i64 + 2×i16) +//! Bytes 1640 – 1739 : CH2 auto-measurements (100 bytes) +//! Bytes 1740 – 2047 : Reserved / padding +//! ``` +//! 300 x-pixels = 12 horizontal divisions × 25 pixels/div. +//! +//! DSO2512G firmware uses slightly different field offsets (see [`DeviceVariant`]). + +use crate::{ADC_VALUE_MAX, ADC_VALUE_MID, Capture, ParseError, PIXELS_PER_DIV}; + +/// Which firmware / hardware variant we're parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DeviceVariant { + /// ZeeWeii DSO3D12 (default, firmware v3.x). + #[default] + Dso3d12, + /// ZeeWeii DSO2512G firmware v2.x. + Dso2512gV2, + /// ZeeWeii DSO2512G firmware v1.x. + Dso2512gV1, +} + +impl std::fmt::Display for DeviceVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DeviceVariant::Dso3d12 => "DSO3D12", + DeviceVariant::Dso2512gV2 => "DSO2512G v2", + DeviceVariant::Dso2512gV1 => "DSO2512G v1", + }) + } +} + + +/// Expected total screenshot packet size. +pub const SCREENSHOT_SIZE: usize = 2048; + +/// Number of waveform x-pixels (12 divisions × 25 px/div). +pub const WAVEFORM_PIXELS: usize = 300; + +// ── Low-level read helpers ──────────────────────────────────────────────── + +#[inline] +fn read_u8(buf: &[u8], off: usize) -> u8 { + buf.get(off).copied().unwrap_or(0) +} + +#[inline] +fn read_u16(buf: &[u8], off: usize) -> u16 { + if off + 2 > buf.len() { return 0; } + u16::from_le_bytes([buf[off], buf[off + 1]]) +} + +#[inline] +fn read_i16(buf: &[u8], off: usize) -> i16 { + if off + 2 > buf.len() { return 0; } + i16::from_le_bytes([buf[off], buf[off + 1]]) +} + +#[inline] +fn read_u32(buf: &[u8], off: usize) -> u32 { + if off + 4 > buf.len() { return 0; } + u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) +} + +#[inline] +fn read_i32(buf: &[u8], off: usize) -> i32 { + if off + 4 > buf.len() { return 0; } + i32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) +} + +#[inline] +fn read_u64(buf: &[u8], off: usize) -> u64 { + if off + 8 > buf.len() { return 0; } + u64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) +} + +#[inline] +fn read_i64(buf: &[u8], off: usize) -> i64 { + if off + 8 > buf.len() { return 0; } + i64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) +} + +// ── Lookup tables ───────────────────────────────────────────────────────── + +/// Timebase index → label (2=5ns … 30=10s). Index 0/1 reserved. +pub const TIMEBASE_LABELS: &[(u8, &str)] = &[ + (0, "1ns"), (1, "2ns"), (2, "5ns"), (3, "10ns"), (4, "20ns"), (5, "50ns"), + (6, "100ns"), (7, "200ns"), (8, "500ns"), (9, "1µs"), (10, "2µs"), (11, "5µs"), + (12, "10µs"), (13, "20µs"), (14, "50µs"), (15, "100µs"), (16, "200µs"), (17, "500µs"), + (18, "1ms"), (19, "2ms"), (20, "5ms"), (21, "10ms"), (22, "20ms"), (23, "50ms"), + (24, "100ms"), (25, "200ms"), (26, "500ms"), (27, "1s"), (28, "2s"), (29, "5s"), + (30, "10s"), +]; + +/// V/Div index → base scale in µV (x1 probe). Keys: 1–13. +pub const VDIV_BASE_UV: &[(u8, u32)] = &[ + (1, 1_000), (2, 2_000), (3, 5_000), (4, 10_000), (5, 20_000), (6, 50_000), + (7, 100_000), (8, 200_000), (9, 500_000), (10, 1_000_000), (11, 2_000_000), + (12, 5_000_000), (13, 10_000_000), +]; + +fn timebase_label(idx: u8) -> &'static str { + TIMEBASE_LABELS.iter().find(|(i, _)| *i == idx).map(|(_, s)| *s).unwrap_or("?") +} + +fn vdiv_label_uv(idx: u8) -> u32 { + VDIV_BASE_UV.iter().find(|(i, _)| *i == idx).map(|(_, v)| *v).unwrap_or(0) +} + +fn probe_label(mode: u8) -> &'static str { + match mode { 0 => "×1", 1 => "×10", 2 => "×100", _ => "?" } +} + +fn run_state_label(s: u8) -> &'static str { + match s { 0 => "Stop", 1 => "Run", 2 => "Wait", _ => "?" } +} + +fn coupling_label(c: u8) -> &'static str { + match c { 0 => "DC", 1 => "AC", _ => "?" } +} + +fn trigger_source_label(s: u8) -> &'static str { + match s { 0 => "CH1", 1 => "CH2", _ => "?" } +} + +fn trigger_mode_label(m: u8) -> &'static str { + match m { 0 => "Auto", 1 => "Normal", _ => "?" } +} + +fn trigger_edge_label(e: u8) -> &'static str { + match e { 0 => "Rise", 1 => "Fall", _ => "?" } +} + +// ── Formatting helpers (mirror ZeeTweak Python) ─────────────────────────── + +/// Format microvolts → human-readable voltage string. +pub fn format_uv(uv: i64, probe_mode: u8) -> String { + let volts = uv as f64 / 1_000_000.0 * (10f64.powi(probe_mode as i32)); + let abs_v = volts.abs(); + if abs_v == 0.0 { return "0.00V".to_string(); } + if abs_v < 0.99995e-3 { return format!("{:.1}µV", volts * 1e6); } + if abs_v < 0.99995 { return format!("{:.1}mV", volts * 1e3); } + if abs_v < 10.0 { return format!("{:.2}V", volts); } + if abs_v < 100.0 { return format!("{:.1}V", volts); } + format!("{:.0}V", volts) +} + +/// Format picoseconds → human-readable time string. +pub fn format_ps(ps: i64) -> String { + let secs = ps as f64 / 1e12; + let val = secs.abs(); + if val == 0.0 { return "0.00s".to_string(); } + if val < 999.5e-9 { let ns = secs * 1e9; return if ns.abs() >= 100.0 { format!("{:.0}ns", ns) } else if ns.abs() >= 10.0 { format!("{:.1}ns", ns) } else { format!("{:.2}ns", ns) }; } + if val < 999.5e-6 { let us = secs * 1e6; return if us.abs() >= 100.0 { format!("{:.0}µs", us) } else if us.abs() >= 10.0 { format!("{:.1}µs", us) } else { format!("{:.2}µs", us) }; } + if val < 0.9995 { let ms = secs * 1e3; return if ms.abs() >= 100.0 { format!("{:.0}ms", ms) } else if ms.abs() >= 10.0 { format!("{:.1}ms", ms) } else { format!("{:.2}ms", ms) }; } + if val >= 100.0 { return format!("{:.0}s", secs); } + if val >= 10.0 { return format!("{:.1}s", secs); } + format!("{:.2}s", secs) +} + +/// Format Hz → human-readable frequency string. +pub fn format_hz(hz: f64) -> String { + if hz < 0.0 { return format_hz(0.0); } + let (val, unit) = if hz >= 999_500.0 { + (hz / 1_000_000.0, "MHz") + } else if hz >= 999.5 { + (hz / 1_000.0, "kHz") + } else { + (hz, "Hz") + }; + if val >= 100.0 { format!("{:.0}{}", val, unit) } + else if val >= 10.0 { format!("{:.1}{}", val, unit) } + else { format!("{:.2}{}", val, unit) } +} + +/// Format Hz → sample-rate string (Sa/s, kSa/s, MSa/s). +pub fn format_sample_rate(hz: f64) -> String { + if hz < 0.0 { return format_sample_rate(0.0); } + let (val, unit) = if hz >= 999_500.0 { + (hz / 1_000_000.0, "MSa/s") + } else if hz >= 999.5 { + (hz / 1_000.0, "kSa/s") + } else { + (hz, "Sa/s") + }; + if val >= 100.0 { format!("{:.0}{}", val, unit) } + else if val >= 10.0 { format!("{:.1}{}", val, unit) } + else { format!("{:.2}{}", val, unit) } +} + +// ── Settings structures ──────────────────────────────────────────────────── + +/// Parsed per-channel settings. +#[derive(Debug, Clone)] +pub struct ChannelSettings { + pub enabled: bool, + pub vdiv_index: u8, + /// Voltage scale in µV per pixel (scale_uv / 25). + pub volt_scale_uv: u32, + /// Zero-volt reference in screen pixels (signed, relative to ADC midpoint 128). + pub zero_volt_pixels: i32, + /// Zero-volt reference in µV. + pub zero_volt_uv: i64, + /// Trigger level in µV. + pub trigger_level_uv: i64, + /// Trigger level in screen pixels. + pub trigger_level_pixels: i32, + /// 0=DC, 1=AC. + pub coupling: u8, + /// 0=×1, 1=×10, 2=×100. + pub probe_mode: u8, + /// Bitmask of enabled auto-measurements. + pub enabled_measurements: u32, +} + +impl ChannelSettings { + /// Converts a raw (inverted) ADC pixel value to voltage in volts. + pub fn pixel_to_volts(&self, raw: u8) -> f64 { + let pixel_val = (ADC_VALUE_MAX - raw) as f64; + let vpp = self.volt_scale_uv as f64 / PIXELS_PER_DIV / 1_000_000.0; + let multiplier = 10f64.powi(self.probe_mode as i32); + (pixel_val - ADC_VALUE_MID as f64 - self.zero_volt_pixels as f64) * vpp * multiplier + } + + pub fn vdiv_str(&self) -> String { + format_uv(vdiv_label_uv(self.vdiv_index) as i64, self.probe_mode) + } +} + +/// Parsed oscilloscope settings (instrument state). +#[derive(Debug, Clone)] +pub struct OscSettings { + pub run_state: u8, + pub system_state: u8, + pub ch1: ChannelSettings, + pub ch2: ChannelSettings, + /// Timebase index (2=5ns … 30=10s). + pub timebase_index: u8, + /// Timebase value in ns/div. + pub timebase_ns_per_div: u64, + /// Horizontal trigger delay in picoseconds. + pub horiz_trigger_delay_ps: i64, + /// Hardware sample rate in Hz. + pub sample_rate_hw_hz: f64, + /// Measurement / display sample rate in Hz. + pub sample_rate_meas_hz: f64, + /// Pre-trigger sample count. + pub pre_trigger_samples: u32, + /// Post-trigger sample count. + pub post_trigger_samples: u32, + /// Total buffer depth in samples. + pub buffer_depth: i64, + /// Trigger source: 0=CH1, 1=CH2. + pub trigger_source: u8, + /// Trigger mode: 0=Auto, 1=Normal. + pub trigger_mode: u8, + /// Trigger edge: 0=Rise, 1=Fall. + pub trigger_edge: u8, + /// Trigger level in ADC counts. + pub trigger_level_adc: u16, + /// Cursor X enable. + pub cursors_x_enable: bool, + /// Cursor Y enable. + pub cursors_y_enable: bool, + /// Cursor positions in pixels (signed, relative to centre pixel 150). + pub cursor_x1: i16, + pub cursor_x2: i16, + /// Cursor Y positions in pixels (signed, relative to ADC zero). + pub cursor_y1: i16, + pub cursor_y2: i16, + /// Currently active channel. + pub active_channel: u8, + /// Display start/end column (viewport into the buffer). + pub display_start_col: u16, + pub display_end_col: u16, +} + +impl OscSettings { + /// Time per pixel in seconds (timebase_ns / 25 / 1e9). + pub fn time_per_pixel_s(&self) -> f64 { + self.timebase_ns_per_div as f64 / PIXELS_PER_DIV / 1e9 + } + + pub fn timebase_label(&self) -> &'static str { + timebase_label(self.timebase_index) + } +} + +// ── Measurement structures ───────────────────────────────────────────────── + +/// Auto-measurement values decoded from a 100-byte measurement block. +/// Layout: 12 × i64 + 2 × i16 (little-endian). +#[derive(Debug, Clone)] +pub struct Measurements { + /// Peak-to-peak voltage (µV). + pub pk_pk_uv: i64, + /// Amplitude (µV). + pub amplitude_uv: i64, + /// Top voltage (µV). + pub top_uv: i64, + /// Base voltage (µV). + pub base_uv: i64, + /// Max voltage (µV). + pub max_uv: i64, + /// Min voltage (µV). + pub min_uv: i64, + /// Average voltage (µV). + pub avg_uv: i64, + /// RMS voltage (µV). + pub rms_uv: i64, + /// Positive pulse width (ps). + pub pos_width_ps: i64, + /// Negative pulse width (ps). + pub neg_width_ps: i64, + /// Period (ps). + pub period_ps: i64, + /// Frequency (µHz — divide by 1e6 to get Hz). + pub freq_uhz: i64, + /// Positive duty cycle (0.1%). + pub pos_duty_tenth_pct: i16, + /// Negative duty cycle (0.1%). + pub neg_duty_tenth_pct: i16, +} + +impl Measurements { + fn parse(buf: &[u8], offset: usize, probe_mode: u8) -> Option { + let chunk = buf.get(offset..offset + 100)?; + if chunk.len() < 100 { return None; } + let v: Vec = (0..12).map(|i| read_i64(chunk, i * 8)).collect(); + let d0 = read_i16(chunk, 96); + let d1 = read_i16(chunk, 98); + let _ = probe_mode; // available for probe-adjusted display + Some(Measurements { + pk_pk_uv: v[0], + amplitude_uv: v[1], + top_uv: v[2], + base_uv: v[3], + max_uv: v[4], + min_uv: v[5], + avg_uv: v[6], + rms_uv: v[7], + pos_width_ps: v[8], + neg_width_ps: v[9], + period_ps: v[10], + freq_uhz: v[11], + pos_duty_tenth_pct: d0, + neg_duty_tenth_pct: d1, + }) + } + + /// Frequency in Hz. + pub fn freq_hz(&self) -> f64 { + self.freq_uhz as f64 / 1_000_000.0 + } + + /// Period in seconds. + pub fn period_s(&self) -> f64 { + self.period_ps as f64 / 1e12 + } + + /// Positive duty cycle (0–100%). + pub fn pos_duty_pct(&self) -> f64 { + self.pos_duty_tenth_pct as f64 / 10.0 + } +} + +// ── Main packet structure ───────────────────────────────────────────────── + +/// A fully-parsed ZeeWeii screenshot packet. +#[derive(Debug, Clone)] +pub struct ScreenshotPacket { + pub device: DeviceVariant, + /// CH1 minimum ADC values per pixel (raw, inverted: real = 255 - raw). + pub ch1_min: [u8; WAVEFORM_PIXELS], + /// CH1 maximum ADC values per pixel. + pub ch1_max: [u8; WAVEFORM_PIXELS], + /// CH2 minimum ADC values (present only when CH2 was enabled on capture). + pub ch2_min: Option<[u8; WAVEFORM_PIXELS]>, + /// CH2 maximum ADC values. + pub ch2_max: Option<[u8; WAVEFORM_PIXELS]>, + /// Oscilloscope settings extracted from the packet. + pub settings: OscSettings, + /// CH1 auto-measurements. + pub ch1_meas: Option, + /// CH2 auto-measurements (only when CH2 enabled). + pub ch2_meas: Option, + /// Length of the original buffer (should be 2048). + pub raw_len: usize, +} + +impl ScreenshotPacket { + /// Convert the min ADC values to a `Capture` compatible with the rest of + /// the GUI pipeline. We use the per-pixel average of min/max for best fidelity. + /// Values are re-mapped from the ZeeTweak pixel coordinate system + /// (128 = zero-volt, inverted) back into the 0–255 range the GUI expects. + pub fn to_capture(&self) -> Capture { + let ch1: Vec = (0..WAVEFORM_PIXELS) + .map(|i| { + let avg = (self.ch1_min[i] as u16 + self.ch1_max[i] as u16) / 2; + avg as u8 + }) + .collect(); + + let ch2: Option> = self.ch2_min.as_ref().map(|mn| { + let mx = self.ch2_max.as_ref().unwrap(); + (0..WAVEFORM_PIXELS) + .map(|i| { + let avg = (mn[i] as u16 + mx[i] as u16) / 2; + avg as u8 + }) + .collect() + }); + + Capture { ch1, ch2 } + } + + /// Convert to physical waveform: returns (time_s, ch1_volts, ch2_volts_opt) + /// for every x pixel, using the calibrated scale from settings. + pub fn to_physical_waveform(&self) -> Vec { + let dt = self.settings.time_per_pixel_s(); + (0..WAVEFORM_PIXELS).map(|i| { + let t = i as f64 * dt; + let ch1_min_v = self.settings.ch1.pixel_to_volts(self.ch1_min[i]); + let ch1_max_v = self.settings.ch1.pixel_to_volts(self.ch1_max[i]); + let ch2 = if self.settings.ch2.enabled { + let mn = self.ch2_min.as_ref().map(|a| a[i]).unwrap_or(ADC_VALUE_MID); + let mx = self.ch2_max.as_ref().map(|a| a[i]).unwrap_or(ADC_VALUE_MID); + Some(( + self.settings.ch2.pixel_to_volts(mn), + self.settings.ch2.pixel_to_volts(mx), + )) + } else { + None + }; + PixelPoint { time_s: t, ch1_min_v, ch1_max_v, ch2 } + }).collect() + } + + /// Build a verbose, human-readable report of the entire packet. + pub fn report_verbose(&self) -> String { + let s = &self.settings; + let mut out = String::new(); + + let bar = "═".repeat(64); + out.push_str(&format!("╔{bar}╗\n")); + out.push_str(&format!("║ ZeeWeii Screenshot Packet — {} \n", self.device)); + out.push_str(&format!("║ Raw buffer: {} bytes\n", self.raw_len)); + out.push_str(&format!("╚{bar}╝\n\n")); + + // Instrument state + out.push_str("── Instrument State ──────────────────────────────────────\n"); + out.push_str(&format!(" Run state : {}\n", run_state_label(s.run_state))); + out.push_str(&format!(" System state : {}\n", s.system_state)); + out.push_str(&format!(" Timebase : {}/div (index {})\n", + timebase_label(s.timebase_index), s.timebase_index)); + out.push_str(&format!(" Timebase raw : {} ns/div\n", s.timebase_ns_per_div)); + out.push_str(&format!(" H trigger delay: {}\n", + format_ps(s.horiz_trigger_delay_ps))); + out.push_str(&format!(" Sample rate HW : {}\n", + format_sample_rate(s.sample_rate_hw_hz))); + out.push_str(&format!(" Sample rate M : {}\n", + format_sample_rate(s.sample_rate_meas_hz))); + out.push_str(&format!(" Buffer depth : {} samples\n", s.buffer_depth)); + out.push_str(&format!(" Pre-trigger : {} samples\n", s.pre_trigger_samples)); + out.push_str(&format!(" Post-trigger : {} samples\n", s.post_trigger_samples)); + out.push_str(&format!(" Display cols : {} – {}\n", + s.display_start_col, s.display_end_col)); + out.push_str(&format!(" Time/pixel : {:.3e} s ({} total for 300 pixels)\n", + s.time_per_pixel_s(), + format_ps((s.time_per_pixel_s() * 300.0 * 1e12) as i64))); + + // Trigger + out.push_str("\n── Trigger ────────────────────────────────────────────────\n"); + out.push_str(&format!(" Source : {}\n", + trigger_source_label(s.trigger_source))); + out.push_str(&format!(" Mode : {}\n", + trigger_mode_label(s.trigger_mode))); + out.push_str(&format!(" Edge : {}\n", + trigger_edge_label(s.trigger_edge))); + out.push_str(&format!(" Level ADC : {} (raw count)\n", s.trigger_level_adc)); + let trig_ch = if s.trigger_source == 0 { &s.ch1 } else { &s.ch2 }; + out.push_str(&format!(" Level voltage : {}\n", + format_uv(trig_ch.trigger_level_uv, trig_ch.probe_mode))); + + // CH1 + out.push_str("\n── Channel 1 ──────────────────────────────────────────────\n"); + out.push_str(&format!(" Enabled : {}\n", s.ch1.enabled)); + out.push_str(&format!(" V/Div : {}/div (index {})\n", + s.ch1.vdiv_str(), s.ch1.vdiv_index)); + out.push_str(&format!(" Volt scale : {} µV/px ({}/pixel)\n", + s.ch1.volt_scale_uv as f64 / PIXELS_PER_DIV, + format_uv((s.ch1.volt_scale_uv as f64 / PIXELS_PER_DIV) as i64, 0))); + out.push_str(&format!(" Zero V (pixels): {} px\n", s.ch1.zero_volt_pixels)); + out.push_str(&format!(" Zero V (µV) : {}\n", + format_uv(s.ch1.zero_volt_uv, 0))); + out.push_str(&format!(" Coupling : {}\n", coupling_label(s.ch1.coupling))); + out.push_str(&format!(" Probe : {}\n", probe_label(s.ch1.probe_mode))); + out.push_str(&format!(" Meas flags : 0x{:04X}\n", s.ch1.enabled_measurements)); + + // CH2 + out.push_str("\n── Channel 2 ──────────────────────────────────────────────\n"); + out.push_str(&format!(" Enabled : {}\n", s.ch2.enabled)); + if s.ch2.enabled { + out.push_str(&format!(" V/Div : {}/div (index {})\n", + s.ch2.vdiv_str(), s.ch2.vdiv_index)); + out.push_str(&format!(" Volt scale : {} µV/px\n", + s.ch2.volt_scale_uv as f64 / PIXELS_PER_DIV)); + out.push_str(&format!(" Zero V (pixels): {} px\n", s.ch2.zero_volt_pixels)); + out.push_str(&format!(" Zero V (µV) : {}\n", + format_uv(s.ch2.zero_volt_uv, 0))); + out.push_str(&format!(" Coupling : {}\n", coupling_label(s.ch2.coupling))); + out.push_str(&format!(" Probe : {}\n", probe_label(s.ch2.probe_mode))); + } + + // Cursors + if s.cursors_x_enable || s.cursors_y_enable { + out.push_str("\n── Cursors ────────────────────────────────────────────────\n"); + } + if s.cursors_x_enable { + let dx_px = (s.cursor_x2 - s.cursor_x1).abs() as f64; + let dt_s = dx_px * s.time_per_pixel_s(); + let dt_ps = (dt_s * 1e12) as i64; + let freq_hz = if dt_s > 0.0 { 1.0 / dt_s } else { 0.0 }; + out.push_str(&format!(" Cursor X1 : {} px (from centre)\n", s.cursor_x1)); + out.push_str(&format!(" Cursor X2 : {} px\n", s.cursor_x2)); + out.push_str(&format!(" Δt : {}\n", format_ps(dt_ps))); + out.push_str(&format!(" Freq (1/Δt) : {}\n", format_hz(freq_hz))); + } + if s.cursors_y_enable { + let act_ch = if s.active_channel == 0 { &s.ch1 } else { &s.ch2 }; + let uv_per_px = act_ch.volt_scale_uv as f64 / PIXELS_PER_DIV; + let dy_px = (s.cursor_y2 - s.cursor_y1).abs() as f64; + let dy_uv = (dy_px * uv_per_px) as i64; + let y1_uv = (s.cursor_y1 as f64 * uv_per_px) as i64; + let y2_uv = (s.cursor_y2 as f64 * uv_per_px) as i64; + out.push_str(&format!(" Cursor Y1 : {} ({} px)\n", + format_uv(y1_uv, act_ch.probe_mode), s.cursor_y1)); + out.push_str(&format!(" Cursor Y2 : {} ({} px)\n", + format_uv(y2_uv, act_ch.probe_mode), s.cursor_y2)); + out.push_str(&format!(" ΔV : {}\n", + format_uv(dy_uv, act_ch.probe_mode))); + } + + // Waveform summary + out.push_str("\n── Waveform Data Summary ───────────────────────────────────\n"); + let points = self.to_physical_waveform(); + let ch1_v: Vec = points.iter().map(|p| (p.ch1_min_v + p.ch1_max_v) / 2.0).collect(); + let ch1_min_v = ch1_v.iter().cloned().fold(f64::INFINITY, f64::min); + let ch1_max_v = ch1_v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let ch1_avg_v = ch1_v.iter().sum::() / ch1_v.len() as f64; + out.push_str(&format!(" CH1 300 pixels, min={:.4}V max={:.4}V avg={:.4}V\n", + ch1_min_v, ch1_max_v, ch1_avg_v)); + if s.ch2.enabled { + let ch2_v: Vec = points.iter().filter_map(|p| p.ch2.map(|(mn, mx)| (mn + mx) / 2.0)).collect(); + if !ch2_v.is_empty() { + let ch2_min_v = ch2_v.iter().cloned().fold(f64::INFINITY, f64::min); + let ch2_max_v = ch2_v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let ch2_avg_v = ch2_v.iter().sum::() / ch2_v.len() as f64; + out.push_str(&format!(" CH2 300 pixels, min={:.4}V max={:.4}V avg={:.4}V\n", + ch2_min_v, ch2_max_v, ch2_avg_v)); + } + } + out.push_str(&format!(" Total time span: {}\n", + format_ps((s.time_per_pixel_s() * WAVEFORM_PIXELS as f64 * 1e12) as i64))); + + // Auto-measurements + if let Some(ref m) = self.ch1_meas { + out.push_str("\n── CH1 Auto-Measurements ───────────────────────────────────\n"); + report_measurements(&mut out, m, s.ch1.probe_mode); + } + if let Some(ref m) = self.ch2_meas { + out.push_str("\n── CH2 Auto-Measurements ───────────────────────────────────\n"); + report_measurements(&mut out, m, s.ch2.probe_mode); + } + + // Raw waveform data (first 16 bytes each channel, hex) + out.push_str("\n── Raw Waveform Bytes (first 16, hex) ──────────────────────\n"); + let hex_row = |label: &str, data: &[u8]| { + let hex: Vec = data.iter().take(16).map(|b| format!("{:02X}", b)).collect(); + format!(" {:<8}: {}\n", label, hex.join(" ")) + }; + out.push_str(&hex_row("CH1 min", &self.ch1_min)); + out.push_str(&hex_row("CH1 max", &self.ch1_max)); + if let (Some(mn), Some(mx)) = (&self.ch2_min, &self.ch2_max) { + out.push_str(&hex_row("CH2 min", mn)); + out.push_str(&hex_row("CH2 max", mx)); + } + + out + } +} + +/// Max plausible measurement voltage (same threshold as container.rs). +const REPORT_MAX_V_UV: i64 = 100_000_000_000; // 100 kV in µV +/// Max plausible pulse width / period (ps). +const REPORT_MAX_T_PS: i64 = 200_000_000_000_000; // 200 s in ps + +fn report_measurements(out: &mut String, m: &Measurements, probe: u8) { + // Format a voltage field, showing "—" for uninitialized garbage. + let fv = |uv: i64| { + let mult = 10i64.pow(probe as u32); + if uv.saturating_mul(mult).abs() > REPORT_MAX_V_UV { + "—".to_string() + } else { + format_uv(uv, probe) + } + }; + // Format a time field (ps), showing "—" for uninitialized garbage. + let ft = |ps: i64| { + if !(0..=REPORT_MAX_T_PS).contains(&ps) { "—".to_string() } else { format_ps(ps) } + }; + + out.push_str(&format!(" Freq : {}\n", format_hz(m.freq_hz()))); + out.push_str(&format!(" Period : {}\n", ft(m.period_ps))); + out.push_str(&format!(" +Width : {}\n", ft(m.pos_width_ps))); + out.push_str(&format!(" -Width : {}\n", ft(m.neg_width_ps))); + out.push_str(&format!(" +Duty : {:.1}%\n", m.pos_duty_pct())); + out.push_str(&format!(" PkPk : {}\n", fv(m.pk_pk_uv))); + out.push_str(&format!(" Amplitude : {}\n", fv(m.amplitude_uv))); + out.push_str(&format!(" Max : {}\n", fv(m.max_uv))); + out.push_str(&format!(" Min : {}\n", fv(m.min_uv))); + out.push_str(&format!(" Top : {}\n", fv(m.top_uv))); + out.push_str(&format!(" Base : {}\n", fv(m.base_uv))); + out.push_str(&format!(" Avg : {}\n", fv(m.avg_uv))); + out.push_str(&format!(" RMS : {}\n", fv(m.rms_uv))); +} + +/// A single calibrated point in the waveform. +#[derive(Debug, Clone)] +pub struct PixelPoint { + pub time_s: f64, + pub ch1_min_v: f64, + pub ch1_max_v: f64, + /// (min_v, max_v) for CH2, or None if CH2 disabled. + pub ch2: Option<(f64, f64)>, +} + +// ── Parsing functions ────────────────────────────────────────────────────── + +/// Parse a 2048-byte screenshot buffer for a DSO3D12. +pub fn parse_screenshot(buf: &[u8]) -> Result { + parse_screenshot_as(buf, DeviceVariant::Dso3d12) +} + +/// Parse a screenshot buffer with an explicit device variant. +pub fn parse_screenshot_as(buf: &[u8], device: DeviceVariant) -> Result { + if buf.len() < 1740 { + return Err(ParseError::TooShort { got: buf.len(), expected: SCREENSHOT_SIZE }); + } + + let mut ch1_min = [0u8; WAVEFORM_PIXELS]; + let mut ch1_max = [0u8; WAVEFORM_PIXELS]; + ch1_min.copy_from_slice(&buf[0..WAVEFORM_PIXELS]); + ch1_max.copy_from_slice(&buf[WAVEFORM_PIXELS..2 * WAVEFORM_PIXELS]); + + let settings = match device { + DeviceVariant::Dso3d12 => parse_settings_dso3d12(buf), + DeviceVariant::Dso2512gV2 => parse_settings_dso2512g_v2(buf), + DeviceVariant::Dso2512gV1 => parse_settings_dso2512g_v1(buf), + }; + + let (ch2_min, ch2_max) = if settings.ch2.enabled { + let mut mn = [0u8; WAVEFORM_PIXELS]; + let mut mx = [0u8; WAVEFORM_PIXELS]; + mn.copy_from_slice(&buf[2 * WAVEFORM_PIXELS..3 * WAVEFORM_PIXELS]); + mx.copy_from_slice(&buf[3 * WAVEFORM_PIXELS..4 * WAVEFORM_PIXELS]); + (Some(mn), Some(mx)) + } else { + (None, None) + }; + + let (meas_ch1_off, meas_ch2_off) = match device { + DeviceVariant::Dso3d12 => (1540, 1640), + DeviceVariant::Dso2512gV2 => (1544, 1648), + DeviceVariant::Dso2512gV1 => (1520, 1624), + }; + + let ch1_meas = Measurements::parse(buf, meas_ch1_off, settings.ch1.probe_mode); + let ch2_meas = if settings.ch2.enabled { + Measurements::parse(buf, meas_ch2_off, settings.ch2.probe_mode) + } else { + None + }; + + Ok(ScreenshotPacket { + device, + ch1_min, + ch1_max, + ch2_min, + ch2_max, + settings, + ch1_meas, + ch2_meas, + raw_len: buf.len(), + }) +} + +fn parse_settings_dso3d12(buf: &[u8]) -> OscSettings { + let ch2_enabled = read_u8(buf, 1202) != 0; + OscSettings { + run_state: read_u8(buf, 1200), + system_state: read_u8(buf, 1201), + buffer_depth: read_i64(buf, 1204), + timebase_index: read_u8(buf, 1216), + timebase_ns_per_div: read_u64(buf, 1220), + horiz_trigger_delay_ps: read_i64(buf, 1228), + sample_rate_hw_hz: read_u32(buf, 1252) as f64 * 100.0, + trigger_source: read_u8(buf, 1256), + trigger_mode: read_u8(buf, 1257), + trigger_edge: read_u8(buf, 1258), + trigger_level_adc: read_u16(buf, 1264), + ch1: ChannelSettings { + enabled: true, + vdiv_index: read_u8(buf, 1266), + volt_scale_uv: read_u32(buf, 1268), + zero_volt_pixels: read_i32(buf, 1272), + zero_volt_uv: read_i64(buf, 1276), + trigger_level_uv: read_i64(buf, 1292), + trigger_level_pixels: read_i32(buf, 1300), + coupling: read_u8(buf, 1304), + probe_mode: read_u8(buf, 1305), + enabled_measurements: read_u32(buf, 1308), + }, + ch2: ChannelSettings { + enabled: ch2_enabled, + vdiv_index: read_u8(buf, 1328), + volt_scale_uv: read_u32(buf, 1332), + zero_volt_pixels: read_i32(buf, 1336), + zero_volt_uv: read_i64(buf, 1340), + trigger_level_uv: read_i64(buf, 1356), + trigger_level_pixels: read_i32(buf, 1364), + coupling: read_u8(buf, 1368), + probe_mode: read_u8(buf, 1369), + enabled_measurements: read_u32(buf, 1372), + }, + sample_rate_meas_hz: read_u64(buf, 1392) as f64, + pre_trigger_samples: read_u32(buf, 1400), + post_trigger_samples: read_u32(buf, 1404), + display_start_col: read_u16(buf, 1408), + display_end_col: read_u16(buf, 1410), + cursors_x_enable: read_u8(buf, 1453) != 0, + cursors_y_enable: read_u8(buf, 1454) != 0, + cursor_x1: read_i16(buf, 1456), + cursor_x2: read_i16(buf, 1458), + cursor_y1: read_i16(buf, 1460), + cursor_y2: read_i16(buf, 1462), + active_channel: read_u8(buf, 1486), + } +} + +fn parse_settings_dso2512g_v2(buf: &[u8]) -> OscSettings { + let ch2_enabled = read_u8(buf, 1202) != 0; + OscSettings { + run_state: read_u8(buf, 1200), + system_state: read_u8(buf, 1201), + buffer_depth: read_u64(buf, 1208) as i64, + timebase_index: read_u8(buf, 1220), + timebase_ns_per_div: read_u64(buf, 1224), + horiz_trigger_delay_ps: read_i64(buf, 1232), + sample_rate_hw_hz: read_u64(buf, 1256) as f64 * 100.0, + trigger_source: read_u8(buf, 1264), + trigger_mode: read_u8(buf, 1265), + trigger_edge: read_u8(buf, 1266), + trigger_level_adc: read_u16(buf, 1272), + ch1: ChannelSettings { + enabled: true, + vdiv_index: read_u8(buf, 1274), + volt_scale_uv: read_u32(buf, 1276), + zero_volt_pixels: read_i32(buf, 1280), + zero_volt_uv: read_i64(buf, 1288), + trigger_level_uv: read_i64(buf, 1304), + trigger_level_pixels: read_i32(buf, 1312), + coupling: read_u8(buf, 1316), + probe_mode: read_u8(buf, 1317), + enabled_measurements: read_u32(buf, 1320), + }, + ch2: ChannelSettings { + enabled: ch2_enabled, + vdiv_index: read_u8(buf, 1340), + volt_scale_uv: read_u32(buf, 1344), + zero_volt_pixels: read_i32(buf, 1348), + zero_volt_uv: read_i64(buf, 1352), + trigger_level_uv: read_i64(buf, 1368), + trigger_level_pixels: read_i32(buf, 1376), + coupling: read_u8(buf, 1380), + probe_mode: read_u8(buf, 1381), + enabled_measurements: read_u32(buf, 1384), + }, + sample_rate_meas_hz: read_u64(buf, 1408) as f64, + pre_trigger_samples: read_u32(buf, 1416), + post_trigger_samples: read_u32(buf, 1420), + display_start_col: read_u16(buf, 1424), + display_end_col: read_u16(buf, 1426), + cursors_x_enable: read_u8(buf, 1469) != 0, + cursors_y_enable: read_u8(buf, 1470) != 0, + cursor_x1: read_i16(buf, 1472), + cursor_x2: read_i16(buf, 1474), + cursor_y1: read_i16(buf, 1476), + cursor_y2: read_i16(buf, 1478), + active_channel: read_u8(buf, 1502), + } +} + +fn parse_settings_dso2512g_v1(buf: &[u8]) -> OscSettings { + // V1 has the same layout as V2 except some minor offsets. + let ch2_enabled = read_u8(buf, 1202) != 0; + OscSettings { + run_state: read_u8(buf, 1200), + system_state: read_u8(buf, 1201), + buffer_depth: read_u64(buf, 1208) as i64, + timebase_index: read_u8(buf, 1220), + timebase_ns_per_div: read_u64(buf, 1224), + horiz_trigger_delay_ps: read_i64(buf, 1232), + sample_rate_hw_hz: read_u64(buf, 1256) as f64 * 100.0, + trigger_source: read_u8(buf, 1264), + trigger_mode: read_u8(buf, 1265), + trigger_edge: read_u8(buf, 1266), + trigger_level_adc: read_u16(buf, 1272), + ch1: ChannelSettings { + enabled: true, + vdiv_index: read_u8(buf, 1274), + volt_scale_uv: read_u32(buf, 1276), + zero_volt_pixels: read_i32(buf, 1280), + zero_volt_uv: read_i64(buf, 1288), + trigger_level_uv: read_i64(buf, 1304), + trigger_level_pixels: read_i32(buf, 1312), + coupling: read_u8(buf, 1316), + probe_mode: read_u8(buf, 1317), + enabled_measurements: read_u32(buf, 1320), + }, + ch2: ChannelSettings { + enabled: ch2_enabled, + vdiv_index: read_u8(buf, 1340), + volt_scale_uv: read_u32(buf, 1344), + zero_volt_pixels: read_i32(buf, 1348), + zero_volt_uv: read_i64(buf, 1352), + trigger_level_uv: read_i64(buf, 1368), + trigger_level_pixels: read_i32(buf, 1376), + coupling: read_u8(buf, 1380), + probe_mode: read_u8(buf, 1381), + enabled_measurements: read_u32(buf, 1384), + }, + sample_rate_meas_hz: read_u64(buf, 1408) as f64 * 10.0, // V1: ×10 + pre_trigger_samples: read_u32(buf, 1416), + post_trigger_samples: read_u32(buf, 1420), + display_start_col: read_u16(buf, 1424), + display_end_col: read_u16(buf, 1426), + cursors_x_enable: read_u8(buf, 1465) != 0, + cursors_y_enable: read_u8(buf, 1466) != 0, + cursor_x1: read_i16(buf, 1468), + cursor_x2: read_i16(buf, 1470), + cursor_y1: read_i16(buf, 1472), + cursor_y2: read_i16(buf, 1474), + active_channel: read_u8(buf, 1484), + } +} + +/// Returns true if the buffer looks like a screenshot packet (size + rough sanity check). +pub fn is_screenshot_packet(buf: &[u8]) -> bool { + // ZeeTweak accepts 2040–2056 bytes as valid screenshot packets. + (2034..=2056).contains(&buf.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_uv_cases() { + assert_eq!(format_uv(0, 0), "0.00V"); + assert_eq!(format_uv(1_000_000, 0), "1.00V"); + assert_eq!(format_uv(500_000, 0), "500.0mV"); + assert_eq!(format_uv(1_000, 0), "1.0mV"); + assert_eq!(format_uv(500, 0), "500.0µV"); + } + + #[test] + fn format_ps_cases() { + assert_eq!(format_ps(0), "0.00s"); + assert_eq!(format_ps(1_000_000_000), "1.00ms"); + assert_eq!(format_ps(1_000_000_000_000), "1.00s"); + } + + #[test] + fn format_hz_cases() { + assert_eq!(format_hz(1000.0), "1.00kHz"); + assert_eq!(format_hz(1_000_000.0), "1.00MHz"); + assert_eq!(format_hz(50.0), "50.0Hz"); + } + + #[test] + fn screenshot_too_short_error() { + let buf = vec![0u8; 100]; + assert!(parse_screenshot(&buf).is_err()); + } + + #[test] + fn is_screenshot_packet_size_check() { + assert!(is_screenshot_packet(&vec![0u8; 2048])); + assert!(is_screenshot_packet(&vec![0u8; 2040])); + assert!(!is_screenshot_packet(&vec![0u8; 1200])); + assert!(!is_screenshot_packet(&[0u8; 100])); + } + + #[test] + fn parse_zeroed_packet() { + // A zeroed 2048-byte buffer should parse without panic. + let buf = vec![0u8; 2048]; + let pkt = parse_screenshot(&buf).unwrap(); + assert!(!pkt.settings.ch2.enabled); + assert_eq!(pkt.ch2_min, None); + let report = pkt.report_verbose(); + assert!(report.contains("DSO3D12")); + } + + #[test] + fn pixel_to_volts_identity() { + // A channel with 100000 µV scale, zero at pixel 0, ×1 probe. + let ch = ChannelSettings { + enabled: true, + vdiv_index: 7, + volt_scale_uv: 100_000, // 100mV/div + zero_volt_pixels: 0, + zero_volt_uv: 0, + trigger_level_uv: 0, + trigger_level_pixels: 0, + coupling: 0, + probe_mode: 0, + enabled_measurements: 0, + }; + // raw=ADC_VALUE_MID → pixel_val=127 → (127-128-0)*scale = -1 × (100000/25/1e6) ≈ -4mV + let v = ch.pixel_to_volts(ADC_VALUE_MID); + assert!((v - -0.004).abs() < 1e-6, "v={v}"); + } +} diff --git a/flasher/Cargo.toml b/flasher/Cargo.toml new file mode 100644 index 0000000..8f4477d --- /dev/null +++ b/flasher/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "wm-tools" +version = "0.1.2" +edition = "2021" +description = "A tool for Winner Micro MCU (including DSO 3D12 oscilloscope firmware)" +repository = "https://github.com/rssdev10/wm-tools" + +[[bin]] +name = "flash" +path = "src/flash.rs" + +[[bin]] +name = "capture" +path = "src/capture.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } +colored = "3.0" +crc = "3.0" +dso-parser = { path = "../dso-parser" } +indicatif = "0.18" +serialport = "4.9.0" +tabled = "0.20" diff --git a/flasher/src/capture.rs b/flasher/src/capture.rs new file mode 100644 index 0000000..207db36 --- /dev/null +++ b/flasher/src/capture.rs @@ -0,0 +1,298 @@ +//! Serial capture tool for ZeeWeii DSO3D12 screenshot packets. +//! +//! # Usage +//! capture [--port /dev/ttyXXX] [--device dso3d12|dso2512g-v2|dso2512g-v1] +//! [--save path.bin] [--timeout 30] +//! capture --load path.bin [--device dso3d12] +//! +//! On the oscilloscope: open *Gallery* → select a saved waveform. +//! The device transmits a 2048-byte binary screenshot packet automatically. + +use anyhow::{Context, Result}; +use clap::Parser; +use dso_parser::{ + is_screenshot_packet, parse_screenshot_as, DeviceVariant, +}; +use serialport::SerialPortType; +use std::{ + io::{Read, Write}, + path::PathBuf, + time::{Duration, Instant}, +}; + +const DEFAULT_PORT: &str = "/dev/tty.usbserial-1110"; +const BAUD: u32 = 115200; + +#[derive(Parser, Debug)] +#[clap( + name = "capture", + about = "Listen for a ZeeWeii DSO3D12 screenshot packet over serial", + after_help = "On the oscilloscope: open Gallery ▸ select a saved waveform to trigger upload." +)] +struct Args { + /// Serial port to open (use -l to list available ports). + #[clap(short, long, default_value = DEFAULT_PORT)] + port: String, + + /// List available serial ports and exit. + #[clap(short, long)] + list: bool, + + /// Device firmware variant: dso3d12 | dso2512g-v2 | dso2512g-v1. + #[clap(short, long, default_value = "dso3d12")] + device: String, + + /// Save the raw binary packet to this file. + #[clap(short, long)] + save: Option, + + /// Load and re-parse a previously saved raw binary file (skips serial capture). + #[clap(long)] + load: Option, + + /// Receive timeout in seconds (0 = wait forever). + #[clap(short, long, default_value = "60")] + timeout: u64, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + if args.list { + list_ports(); + return Ok(()); + } + + let device = parse_device_variant(&args.device)?; + + // Re-parse mode: load from file instead of serial. + let buf = if let Some(ref path) = args.load { + println!("Loading {} …", path.display()); + std::fs::read(path) + .with_context(|| format!("Cannot read '{}'", path.display()))? + } else { + println!("Opening {} at {} baud …", args.port, BAUD); + let mut port = serialport::new(&args.port, BAUD) + .timeout(Duration::from_millis(200)) + .open() + .with_context(|| format!("Cannot open serial port '{}'", args.port))?; + + println!("Waiting for screenshot packet."); + println!(); + println!(" ► On the oscilloscope: open Gallery ▸ select a saved waveform."); + println!(" ► The device will transmit a 2048-byte binary packet automatically."); + println!(); + if args.timeout > 0 { + println!(" (Timeout: {} s)", args.timeout); + } else { + println!(" (Waiting indefinitely — Ctrl-C to abort)"); + } + println!(); + + let buf = receive_packet(&mut *port, args.timeout)?; + + println!(" Received {} bytes.", buf.len()); + + if let Some(ref path) = args.save { + std::fs::write(path, &buf) + .with_context(|| format!("Cannot save to {}", path.display()))?; + println!(" Saved raw packet → {}", path.display()); + } + + buf + }; + + println!(); + println!("{}", "═".repeat(66)); + println!(" Parsing as {} …", device); + println!("{}", "═".repeat(66)); + + match parse_screenshot_as(&buf, device) { + Ok(pkt) => { + println!("{}", pkt.report_verbose()); + + // Additional graph-usage notes + println!("{}", "═".repeat(66)); + println!(" GRAPH USAGE SUMMARY"); + println!("{}", "═".repeat(66)); + let s = &pkt.settings; + let points = pkt.to_physical_waveform(); + println!(" Samples : {} (300 pixels = 12 div × 25 px/div)", points.len()); + println!( + " X axis : 0 … {} (each step = {:.3e} s)", + dso_parser::format_ps( + (s.time_per_pixel_s() * 299.0 * 1e12) as i64 + ), + s.time_per_pixel_s() + ); + let half_range_v = s.ch1.volt_scale_uv as f64 * 4.0 / 1_000_000.0 + * 10f64.powi(s.ch1.probe_mode as i32); + println!( + " Y axis (CH1) : ≈ {:.4}V … {:.4}V ({}/div)", + -half_range_v, + half_range_v, + s.ch1.vdiv_str() + ); + if s.ch2.enabled { + let half_range_v2 = s.ch2.volt_scale_uv as f64 * 4.0 / 1_000_000.0 + * 10f64.powi(s.ch2.probe_mode as i32); + println!( + " Y axis (CH2) : ≈ {:.4}V … {:.4}V ({}/div)", + -half_range_v2, + half_range_v2, + s.ch2.vdiv_str() + ); + } + + println!(); + println!(" Data structure for plotting:"); + println!(" Each PixelPoint {{ time_s, ch1_min_v, ch1_max_v, ch2: Option<(min_v, max_v)> }}"); + println!(" Fill-between ch1_min_v and ch1_max_v for waveform envelope."); + println!(" Average (min+max)/2 per pixel for a clean single trace."); + println!(); + println!(" First 8 calibrated points:"); + for (i, p) in points.iter().take(8).enumerate() { + if let Some((c2min, c2max)) = p.ch2 { + println!( + " [{:3}] t={:.3e}s CH1 {:.4}…{:.4}V CH2 {:.4}…{:.4}V", + i, p.time_s, p.ch1_min_v, p.ch1_max_v, c2min, c2max + ); + } else { + println!( + " [{:3}] t={:.3e}s CH1 {:.4}…{:.4}V", + i, p.time_s, p.ch1_min_v, p.ch1_max_v + ); + } + } + } + Err(e) => { + eprintln!("Parse error: {e}"); + eprintln!("Raw hex dump (first 64 bytes):"); + for (i, b) in buf.iter().take(64).enumerate() { + if i % 16 == 0 { eprint!("\n {:04X} ", i); } + eprint!("{:02X} ", b); + } + eprintln!(); + std::process::exit(1); + } + } + + Ok(()) +} + +/// Read bytes from the serial port until we have a complete screenshot packet +/// or the timeout expires. Returns the raw buffer. +fn receive_packet(port: &mut dyn Read, timeout_secs: u64) -> Result> { + let mut buf: Vec = Vec::with_capacity(2048); + let mut read_buf = [0u8; 512]; + let deadline = if timeout_secs > 0 { + Some(Instant::now() + Duration::from_secs(timeout_secs)) + } else { + None + }; + let mut last_recv = Instant::now(); + + loop { + // Timeout check + if let Some(d) = deadline { + if Instant::now() > d { + anyhow::bail!("Timeout: no packet received within {} s", timeout_secs); + } + } + + match port.read(&mut read_buf) { + Ok(0) => {} + Ok(n) => { + buf.extend_from_slice(&read_buf[..n]); + last_recv = Instant::now(); + + // Flush progress indicator + print!("\r Buffered {} bytes … ", buf.len()); + let _ = std::io::stdout().flush(); + + // Once we have at least 2034 bytes and 200 ms have elapsed + // with no new data, consider the packet complete. + // ZeeTweak accepts 2034-2056 bytes. + if buf.len() >= 2034 + && last_recv.elapsed() >= Duration::from_millis(200) + { + println!(); + break; + } + + // Hard upper bound — if we received more than 2200 bytes, + // something went wrong (debug dump mixed in?). + if buf.len() > 2200 { + println!(); + eprintln!("Warning: buffer overflow ({} bytes); truncating to 2048", buf.len()); + buf.truncate(2048); + break; + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::TimedOut + || e.kind() == std::io::ErrorKind::WouldBlock => + { + // 200 ms read timeout — if we have enough bytes and no recent + // activity, declare the packet complete. + if buf.len() >= 2034 + && last_recv.elapsed() >= Duration::from_millis(200) + { + println!(); + break; + } + } + Err(e) => { + return Err(e).context("Serial read error"); + } + } + } + + if !is_screenshot_packet(&buf) { + anyhow::bail!( + "Received {} bytes — does not match screenshot packet size (2034–2056).\n\ + This may be a debug dump or noise. Re-trigger on the device.", + buf.len() + ); + } + + Ok(buf) +} + +fn parse_device_variant(s: &str) -> Result { + match s.to_lowercase().as_str() { + "dso3d12" => Ok(DeviceVariant::Dso3d12), + "dso2512g-v2" | "dso2512gv2" | "v2" => Ok(DeviceVariant::Dso2512gV2), + "dso2512g-v1" | "dso2512gv1" | "v1" => Ok(DeviceVariant::Dso2512gV1), + other => anyhow::bail!( + "Unknown device variant '{}'. Use: dso3d12, dso2512g-v2, dso2512g-v1", + other + ), + } +} + +fn list_ports() { + println!("Available serial ports:"); + match serialport::available_ports() { + Ok(ports) => { + if ports.is_empty() { + println!(" (none found)"); + return; + } + for p in &ports { + let info = match &p.port_type { + SerialPortType::UsbPort(i) => { + let mfr = i.manufacturer.as_deref().unwrap_or("?"); + let prod = i.product.as_deref().unwrap_or("?"); + format!(" USB {mfr} / {prod}") + } + SerialPortType::BluetoothPort => " Bluetooth".to_string(), + SerialPortType::PciPort => " PCI".to_string(), + SerialPortType::Unknown => " (unknown)".to_string(), + }; + println!(" {} {}", p.port_name, info); + } + } + Err(e) => println!(" Error listing ports: {e}"), + } +} diff --git a/src/flash.rs b/flasher/src/flash.rs similarity index 99% rename from src/flash.rs rename to flasher/src/flash.rs index 5b472de..e47866d 100644 --- a/src/flash.rs +++ b/flasher/src/flash.rs @@ -348,6 +348,7 @@ fn list_serial_ports() -> io::Result<()> { } #[cfg(windows)] { + let _ = port; true } }) diff --git a/gui/Cargo.toml b/gui/Cargo.toml new file mode 100644 index 0000000..214f819 --- /dev/null +++ b/gui/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "dso3d12-gui" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "GUI viewer for ZeeWeii DSO3D12 oscilloscope raw debug dumps" + +[[bin]] +name = "dso3d12-gui" +path = "src/main.rs" + +[dependencies] +dso-parser = { path = "../dso-parser" } +anyhow = { workspace = true } +log = { workspace = true, features = ["std"] } +thiserror = { workspace = true } +iced = { version = "0.14", features = ["canvas", "tokio", "advanced", "image"] } +iced_aw = { version = "0.14", features = ["context_menu"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rfd = "0.15" +dirs = "6.0" +csv = "1.3" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +serialport = "4.6" +tokio = { version = "1", features = ["sync", "time"] } +crc = "3.2" +open = "5" +ab_glyph = "0.2" +png = { workspace = true } +tiny-skia = { workspace = true } +font-kit = "0.14" +rust-i18n = { workspace = true } +sys-locale = "0.3" + + +[dev-dependencies] +ab_glyph = "0.2" diff --git a/gui/img/dso3d12_ico.png b/gui/img/dso3d12_ico.png new file mode 100644 index 0000000..fa7ba2f Binary files /dev/null and b/gui/img/dso3d12_ico.png differ diff --git a/gui/locales/en.json b/gui/locales/en.json new file mode 100644 index 0000000..11829a6 --- /dev/null +++ b/gui/locales/en.json @@ -0,0 +1,156 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "Ready.", + "app.settings_saved": "Settings saved.", + "app.no_port": "No port", + "app.no_capture": "No capture to export.", + "app.no_capture_loaded": "No capture loaded — use Load Capture…", + "app.select_port": "Select a serial port first.", + "btn.help": "Help", + "btn.stop": "Stop", + "btn.listen": "Listen", + "btn.close": "Close", + "btn.save": "Save & Close", + "btn.update": "Update", + "btn.confirm_update": "Confirm Update", + "btn.updating": "Updating…", + "btn.browse": "Browse…", + "btn.refresh": "Refresh", + "btn.load_capture": "Load Capture", + "btn.save_capture": "Save Capture", + "btn.save_png": "Save PNG", + "btn.export_data": "Export Data", + "btn.settings": "Settings", + "btn.help_open": "Help", + "btn.firmware_update": "Firmware Update…", + "btn.undo_delete": "Undo Delete", + "btn.export_png": "Export PNG…", + "btn.export_csv": "Export CSV…", + "btn.delete": "Delete", + "label.measurement": "Measurement", + "label.cursor_x": "Cursor X (time)", + "label.cursor_y": "Cursor Y (voltage)", + "label.freq": "≈ freq: %{hz}", + "label.dt": "Δt = %{ms} ms", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/div, V:", + "label.probe": "Probe:", + "label.voff": "V/off, V:", + "label.tdiv": "t/div, ms:", + "label.ac_mode": "AC mode: 8×V, 12×t div", + "label.time_range": "Time range: %{ms} ms (%{n} samp)", + "label.ch1_stats": "CH1: n=%{n} min=%{min} max=%{max} avg=%{avg}", + "label.ch2_stats": "CH2: n=%{n} min=%{min} max=%{max} avg=%{avg}", + "label.trigger": "Trigger", + "label.trigger_info": "Mode: %{mode} | Edge: %{edge} | Src: %{src}", + "label.trigger_level": "Level: %{v} V", + "label.ch1_probe": "CH1: %{probe} probe, %{coupling}", + "label.ch2_probe": "CH2: %{probe} probe, %{coupling}", + "label.delay_rate": "Delay: %{delay} s | Rate: %{rate} Sa/s", + "label.auto_meas": "Auto Measurements", + "label.device_x": "Device X", + "label.device_y": "Device Y", + "label.scales": "Scales", + "label.view": "View:", + "label.split_ch": "Split CH1/CH2", + "label.show_instructions": "Show instructions", + "btn.load_settings": "Settings", + "label.listening_on": "Listening on %{port}…", + "label.stopped": "Stopped.", + "label.connected_to": "Connected to %{port}.", + "label.disconnected": "Port disconnected; reconnecting…", + "label.receiving": "Receiving… %{n} bytes", + "label.screenshot_received": "Screenshot received (CH1: %{ch1}/div, CH2: %{ch2}/div, %{timebase}/div). Total: %{total}", + "label.received_captures": "Received %{count} capture(s). Total: %{total}", + "label.saved_ok": "Saved %{n} capture(s) to %{path}", + "label.save_error": "Save error: %{e}", + "label.png_saved": "PNG saved to %{path}", + "label.png_error": "PNG error: %{e}", + "label.csv_written": "Wrote %{path}", + "label.csv_error": "CSV error: %{e}", + "label.loaded_ok": "Loaded %{n} capture(s) from %{path}", + "label.load_error": "Load error: %{e}", + "label.deleted": "Deleted capture #%{n}. Press Undo to restore.", + "label.restored": "Restored capture #%{n}.", + "label.undo_expired": "Undo expired.", + "label.settings_title": "Settings", + "label.serial_section": "Serial", + "label.default_port": "Default port:", + "label.available_ports": "Available: %{ports}", + "label.no_ports": "No serial ports detected.", + "label.baud_rate": "Baud rate:", + "label.auto_listen": "Auto-listen on startup", + "label.beep_on_capture": "Beep on new capture", + "label.display_section": "Display", + "label.language": "Language", + "label.png_width": "PNG width, px (0=small):", + "label.about_section": "About", + "label.repository": "Repository:", + "label.config_path": "Config path: ", + "label.instructions_title": "Instructions", + "label.standard_fw_title": "Standard firmware — raw debug dump", + "label.standard_fw_1": "1. Connect the oscilloscope to the computer using a USB Type-C cable.", + "label.standard_fw_2": "2. Feed a signal into the oscilloscope.", + "label.standard_fw_3": "3. Press Stop to freeze the sample buffer.", + "label.standard_fw_4": "4. Press Menu → Stop and leave the Channel 1 Measurements menu open.", + "label.standard_fw_5": "5. In the application, select the serial port and click Listen.", + "label.standard_fw_6": "6. Long-press the Save button on the oscilloscope to start transmitting the debug data.", + "label.standard_fw_7": "7. Wait for the transfer to complete. The oscilloscope sends approximately 300 kB at 115200 baud.", + "label.customized_fw_title": "Customized firmware", + "label.customized_fw_1": "1. Connect the oscilloscope to the computer using a USB Type-C cable.", + "label.customized_fw_2": "2. In the application, select the serial port and click Listen.", + "label.customized_fw_3": "3. Capture a waveform on the oscilloscope by pressing Save while acquisition is running or stopped.", + "label.customized_fw_4": "4. Press Menu → Gallery to open the saved screenshots.", + "label.customized_fw_5": "5. Select the waveform that you want to transmit.", + "label.customized_fw_6": "6. The oscilloscope automatically sends a 2048-byte binary packet over the USB serial connection.", + "label.customized_fw_7": "7. The packet includes waveform data and metadata such as V/div, timebase, trigger settings, cursors, and measurements.", + "label.using_viewer_title": "Using the waveform viewer", + "label.using_viewer_1": "The application can receive oscilloscope data in both supported transmission modes.", + "label.using_viewer_2": "After a waveform is received, you can inspect it, measure voltage and time intervals with cursors, and export the result as a PNG image or CSV data.", + "label.using_viewer_3": "Before receiving data, connect the oscilloscope using a USB Type-C cable, select the correct serial port, and click Listen. Keep the application listening while starting the transmission from the oscilloscope.", + "label.firmware_title": "Firmware Update", + "label.firmware_warning": "⚠ WARNING: Flashing incorrect firmware may brick your device. Ensure the device is in boot mode and the correct firmware file is selected.", + "label.boot_title": "Switching to Boot Mode (DSO3D12)", + "label.boot_1": "1. Connect the supplied USB cable to your PC while the oscilloscope is powered off.", + "label.boot_2": "2. Press and hold the power button. The scope may enter a power-cycling loop — keep the button held down continuously until flashing reaches 100%.", + "label.port": "Port:", + "label.firmware_file": "Firmware file (.fls):", + "label.no_file": "No file selected", + "label.firmware_status_ready": "Select firmware file and port, then click Update.", + "label.firmware_confirm": "⚠ Are you sure? This may brick the device. Click Update again to confirm.", + "label.firmware_no_file": "No firmware file selected.", + "label.firmware_no_port": "No port selected.", + "label.firmware_starting": "Starting…", + "label.firmware_complete": "Flash complete! You may close this dialog.", + "label.firmware_error": "Flash error: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} blocks)", + "label.no_data": "No data", + "label.all_files": "All files", + "label.zwcap_filter": "ZeeWeii Capture", + "label.csv_filter": "CSV", + "label.png_filter": "PNG Image", + "label.fls_filter": "Firmware", + "label.freq_name": "Freq", + "label.pkpk_name": "PkPk", + "label.avg_name": "Avg", + "label.rms_name": "RMS", + "label.amp_name": "Amp", + "label.duty_plus_name": "+Duty", + "label.duty_minus_name": "-Duty", + "label.t_plus_name": "+T", + "label.t_minus_name": "-T", + "label.t_name": "T", + "label.max_name": "Max", + "label.min_name": "Min", + "label.top_name": "Top", + "label.base_name": "Base", + "label.cursor_x_range": "Cursor X (time)", + "label.cursor_y_range": "Cursor Y (voltage)", + "label.ctrl_fine_drag": "Hold CTRL for fine drag", + "label.view_mode_line": "Line", + "label.view_mode_dot": "Dot", + "label.view_mode_smooth": "Smooth" +} diff --git a/gui/locales/es.json b/gui/locales/es.json new file mode 100644 index 0000000..13a8006 --- /dev/null +++ b/gui/locales/es.json @@ -0,0 +1,155 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "Listo.", + "app.settings_saved": "Ajustes guardados.", + "app.no_port": "Sin puerto", + "app.no_capture": "No hay captura para exportar.", + "app.no_capture_loaded": "No hay captura cargada — use Cargar captura…", + "app.select_port": "Seleccione un puerto serie primero.", + "btn.help": "Ayuda", + "btn.stop": "Detener", + "btn.listen": "Escuchar", + "btn.close": "Cerrar", + "btn.save": "Guardar y cerrar", + "btn.update": "Actualizar", + "btn.confirm_update": "Confirmar actualización", + "btn.updating": "Actualizando…", + "btn.browse": "Examinar…", + "btn.refresh": "Actualizar", + "btn.load_capture": "Cargar captura", + "btn.save_capture": "Guardar captura", + "btn.save_png": "Guardar PNG", + "btn.export_data": "Exportar datos", + "btn.settings": "Ajustes", + "btn.help_open": "Ayuda", + "btn.firmware_update": "Actualizar firmware…", + "btn.undo_delete": "Deshacer eliminar", + "btn.export_png": "Exportar PNG…", + "btn.export_csv": "Exportar CSV…", + "btn.delete": "Eliminar", + "label.measurement": "Medición", + "label.cursor_x": "Cursor X (tiempo)", + "label.cursor_y": "Cursor Y (voltaje)", + "label.freq": "≈ frecuencia: %{hz}", + "label.dt": "Δt = %{ms} ms", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/div, V:", + "label.probe": "Factor:", + "label.voff": "Despl V, V:", + "label.tdiv": "t/div, ms:", + "label.ac_mode": "Modo CA: 8×V, 12×t div", + "label.time_range": "Tiempo: %{ms} ms (%{n} muestras)", + "label.ch1_stats": "CH1: n=%{n} mín=%{min} máx=%{max} prom=%{avg}", + "label.ch2_stats": "CH2: n=%{n} mín=%{min} máx=%{max} prom=%{avg}", + "label.trigger": "Disparo", + "label.trigger_info": "Modo: %{mode} | Flanco: %{edge} | Fte: %{src}", + "label.trigger_level": "Nivel: %{v} V", + "label.ch1_probe": "CH1: factor %{probe}, %{coupling}", + "label.ch2_probe": "CH2: factor %{probe}, %{coupling}", + "label.delay_rate": "Retardo: %{delay} s | Frec: %{rate} M/s", + "label.auto_meas": "Mediciones auto", + "label.device_x": "Cursor X", + "label.device_y": "Cursor Y", + "label.scales": "Escalas", + "label.view": "Vista:", + "label.split_ch": "Dividir CH1/CH2", + "label.show_instructions": "Mostrar instrucciones", + "label.listening_on": "Escuchando en %{port}…", + "label.stopped": "Detenido.", + "label.connected_to": "Conectado a %{port}.", + "label.disconnected": "Puerto desconectado; reconectando…", + "label.receiving": "Recibiendo… %{n} bytes", + "label.screenshot_received": "Captura recibida (CH1: %{ch1}/div, CH2: %{ch2}/div, %{timebase}/div). Total: %{total}", + "label.received_captures": "Recibidas %{count} captura(s). Total: %{total}", + "label.saved_ok": "Guardadas %{n} captura(s) en %{path}", + "label.save_error": "Error al guardar: %{e}", + "label.png_saved": "PNG guardado en %{path}", + "label.png_error": "Error PNG: %{e}", + "label.csv_written": "Escrito %{path}", + "label.csv_error": "Error CSV: %{e}", + "label.loaded_ok": "Cargadas %{n} captura(s) desde %{path}", + "label.load_error": "Error de carga: %{e}", + "label.deleted": "Captura #%{n} eliminada. Pulse Deshacer para restaurar.", + "label.restored": "Captura #%{n} restaurada.", + "label.undo_expired": "Deshacer expirado.", + "label.settings_title": "Ajustes", + "label.serial_section": "Puerto serie", + "label.default_port": "Puerto predeterminado:", + "label.available_ports": "Disponibles: %{ports}", + "label.no_ports": "No se detectaron puertos serie.", + "label.baud_rate": "Velocidad (baudios):", + "label.auto_listen": "Escucha automática al inicio", + "label.beep_on_capture": "Sonido al recibir captura", + "label.display_section": "Pantalla", + "label.language": "Idioma", + "label.png_width": "Ancho PNG, px (0=peq.):", + "label.about_section": "Acerca de", + "label.repository": "Repositorio:", + "label.config_path": "Config: ", + "label.instructions_title": "Instrucciones", + "label.standard_fw_title": "Firmware estándar — volcado de datos", + "label.standard_fw_1": "1. Conecte el osciloscopio al ordenador mediante USB Type-C.", + "label.standard_fw_2": "2. Introduzca una señal en el osciloscopio.", + "label.standard_fw_3": "3. Pulse Stop para congelar el buffer.", + "label.standard_fw_4": "4. Pulse Menu → Stop y deje abierto el menú de mediciones del Canal 1.", + "label.standard_fw_5": "5. En la aplicación, seleccione el puerto serie y pulse Escuchar.", + "label.standard_fw_6": "6. Mantenga pulsado Save en el osciloscopio para transmitir los datos.", + "label.standard_fw_7": "7. Espere a que termine la transferencia (~300 kB a 115200 baudios).", + "label.customized_fw_title": "Firmware personalizado", + "label.customized_fw_1": "1. Conecte el osciloscopio al ordenador mediante USB Type-C.", + "label.customized_fw_2": "2. En la aplicación, seleccione el puerto y pulse Escuchar.", + "label.customized_fw_3": "3. Capture una forma de onda pulsando Save durante la adquisición.", + "label.customized_fw_4": "4. Pulse Menu → Gallery para abrir las capturas guardadas.", + "label.customized_fw_5": "5. Seleccione la forma de onda a transmitir.", + "label.customized_fw_6": "6. El osciloscopio enviará automáticamente un paquete binario de 2048 bytes.", + "label.customized_fw_7": "7. El paquete incluye datos de forma de onda y metadatos: V/div, base de tiempo, disparo, cursores y mediciones.", + "label.using_viewer_title": "Uso del visor", + "label.using_viewer_1": "La aplicación puede recibir datos del osciloscopio en ambos modos de transmisión compatibles.", + "label.using_viewer_2": "Tras recibir una forma de onda, puede inspeccionarla, medir voltaje y tiempo con cursores, y exportar a PNG o CSV.", + "label.using_viewer_3": "Antes de recibir datos, conecte el osciloscopio por USB Type-C, seleccione el puerto y pulse Escuchar.", + "label.firmware_title": "Actualización de firmware", + "label.firmware_warning": "⚠ ADVERTENCIA: Un firmware incorrecto puede dañar el dispositivo. Asegúrese de que el dispositivo esté en modo boot y el archivo correcto.", + "label.boot_title": "Cambio a modo boot (DSO3D12)", + "label.boot_1": "1. Conecte el cable USB al PC con el osciloscopio apagado.", + "label.boot_2": "2. Mantenga pulsado el botón de encendido. El osciloscopio puede reiniciarse en ciclo — siga pulsando hasta que la actualización llegue al 100%.", + "label.port": "Puerto:", + "label.firmware_file": "Archivo de firmware (.fls):", + "label.no_file": "Ningún archivo seleccionado", + "label.firmware_status_ready": "Seleccione el archivo de firmware y el puerto, luego pulse Actualizar.", + "label.firmware_confirm": "⚠ ¿Está seguro? Un firmware incorrecto puede dañar el dispositivo. Pulse Actualizar de nuevo para confirmar.", + "label.firmware_no_file": "Ningún archivo de firmware seleccionado.", + "label.firmware_no_port": "Ningún puerto seleccionado.", + "label.firmware_starting": "Iniciando…", + "label.firmware_complete": "¡Actualización completada! Puede cerrar este diálogo.", + "label.firmware_error": "Error de actualización: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} bloques)", + "label.no_data": "Sin datos", + "label.all_files": "Todos los archivos", + "label.zwcap_filter": "Captura ZeeWeii", + "label.csv_filter": "CSV", + "label.png_filter": "Imagen PNG", + "label.fls_filter": "Firmware", + "label.freq_name": "Frec", + "label.pkpk_name": "Pico", + "label.avg_name": "Prom", + "label.rms_name": "RMS", + "label.amp_name": "Amp", + "label.duty_plus_name": "+Ciclo", + "label.duty_minus_name": "-Ciclo", + "label.t_plus_name": "+T", + "label.t_minus_name": "-T", + "label.t_name": "T", + "label.max_name": "Máx", + "label.min_name": "Mín", + "label.top_name": "Sup", + "label.base_name": "Base", + "label.cursor_x_range": "Cursor X (tiempo)", + "label.cursor_y_range": "Cursor Y (voltaje)", + "label.ctrl_fine_drag": "Mantén CTRL para arrastre fino", + "label.view_mode_line": "Línea", + "label.view_mode_dot": "Punto", + "label.view_mode_smooth": "Suave" +} diff --git a/gui/locales/hi.json b/gui/locales/hi.json new file mode 100644 index 0000000..86d3a6c --- /dev/null +++ b/gui/locales/hi.json @@ -0,0 +1,155 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "तैयार।", + "app.settings_saved": "सेटिंग्स सहेजी गईं।", + "app.no_port": "कोई पोर्ट नहीं", + "app.no_capture": "निर्यात करने के लिए कोई कैप्चर नहीं।", + "app.no_capture_loaded": "कोई कैप्चर लोड नहीं — लोड कैप्चर का उपयोग करें…", + "app.select_port": "कृपया पहले एक सीरियल पोर्ट चुनें।", + "btn.help": "सहायता", + "btn.stop": "रोकें", + "btn.listen": "सुनें", + "btn.close": "बंद करें", + "btn.save": "सहेजें और बंद करें", + "btn.update": "अपडेट करें", + "btn.confirm_update": "अपडेट की पुष्टि करें", + "btn.updating": "अपडेट हो रहा है…", + "btn.browse": "ब्राउज़ करें…", + "btn.refresh": "ताज़ा करें", + "btn.load_capture": "कैप्चर लोड करें", + "btn.save_capture": "कैप्चर सहेजें", + "btn.save_png": "PNG सहेजें", + "btn.export_data": "डेटा निर्यात करें", + "btn.settings": "सेटिंग्स", + "btn.help_open": "सहायता", + "btn.firmware_update": "फ़र्मवेयर अपडेट…", + "btn.undo_delete": "हटाना पूर्ववत करें", + "btn.export_png": "PNG निर्यात…", + "btn.export_csv": "CSV निर्यात…", + "btn.delete": "हटाएँ", + "label.measurement": "माप", + "label.cursor_x": "कर्सर X (समय)", + "label.cursor_y": "कर्सर Y (वोल्टेज)", + "label.freq": "≈ आवृत्ति: %{hz}", + "label.dt": "Δt = %{ms} मि.से.", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/डिव, V:", + "label.probe": "फैक्टर:", + "label.voff": "V ऑफ़सेट, V:", + "label.tdiv": "t/डिव, मि.से.:", + "label.ac_mode": "AC मोड: 8×V, 12×t डिव", + "label.time_range": "समय: %{ms} मि.से. (%{n} नमूने)", + "label.ch1_stats": "CH1: n=%{n} न्यून=%{min} अधि=%{max} औसत=%{avg}", + "label.ch2_stats": "CH2: n=%{n} न्यून=%{min} अधि=%{max} औसत=%{avg}", + "label.trigger": "ट्रिगर", + "label.trigger_info": "मोड: %{mode} | किनारा: %{edge} | स्रोत: %{src}", + "label.trigger_level": "स्तर: %{v} V", + "label.ch1_probe": "CH1: फैक्टर %{probe}, %{coupling}", + "label.ch2_probe": "CH2: फैक्टर %{probe}, %{coupling}", + "label.delay_rate": "विलंब: %{delay} से | दर: %{rate} Sa/s", + "label.auto_meas": "स्वचालित माप", + "label.device_x": "डिवाइस कर्सर X", + "label.device_y": "डिवाइस कर्सर Y", + "label.scales": "स्केल", + "label.view": "दृश्य:", + "label.split_ch": "CH1/CH2 विभाजित करें", + "label.show_instructions": "निर्देश दिखाएँ", + "label.listening_on": "%{port} पर सुन रहा है…", + "label.stopped": "रोक दिया गया।", + "label.connected_to": "%{port} से कनेक्टेड।", + "label.disconnected": "पोर्ट डिस्कनेक्ट; पुनः कनेक्ट हो रहा है…", + "label.receiving": "प्राप्त हो रहा है… %{n} बाइट", + "label.screenshot_received": "स्क्रीनशॉट प्राप्त (CH1: %{ch1}/डिव, CH2: %{ch2}/डिव, %{timebase}/डिव)। कुल: %{total}", + "label.received_captures": "%{count} कैप्चर प्राप्त। कुल: %{total}", + "label.saved_ok": "%{n} कैप्चर %{path} में सहेजे गए", + "label.save_error": "सहेजने में त्रुटि: %{e}", + "label.png_saved": "PNG %{path} में सहेजा गया", + "label.png_error": "PNG त्रुटि: %{e}", + "label.csv_written": "%{path} लिखा गया", + "label.csv_error": "CSV त्रुटि: %{e}", + "label.loaded_ok": "%{path} से %{n} कैप्चर लोड हुए", + "label.load_error": "लोड त्रुटि: %{e}", + "label.deleted": "कैप्चर #%{n} हटाया गया। पूर्ववत करने के लिए दबाएँ।", + "label.restored": "कैप्चर #%{n} पुनर्स्थापित।", + "label.undo_expired": "पूर्ववत समाप्त।", + "label.settings_title": "सेटिंग्स", + "label.serial_section": "सीरियल", + "label.default_port": "डिफ़ॉल्ट पोर्ट:", + "label.available_ports": "उपलब्ध: %{ports}", + "label.no_ports": "कोई सीरियल पोर्ट नहीं मिला।", + "label.baud_rate": "बॉड दर:", + "label.auto_listen": "स्टार्टअप पर स्वचालित सुनें", + "label.beep_on_capture": "नए कैप्चर पर बीप", + "label.display_section": "प्रदर्शन", + "label.language": "भाषा", + "label.png_width": "PNG चौड़ाई, px (0=छोटा):", + "label.about_section": "के बारे में", + "label.repository": "भंडार:", + "label.config_path": "कॉन्फ़िग: ", + "label.instructions_title": "निर्देश", + "label.standard_fw_title": "मानक फ़र्मवेयर — डेटा डंप", + "label.standard_fw_1": "1. USB Type-C केबल से ऑसिलोस्कोप को कंप्यूटर से कनेक्ट करें।", + "label.standard_fw_2": "2. ऑसिलोस्कोप में सिग्नल डालें।", + "label.standard_fw_3": "3. सैंपल बफ़र फ़्रीज़ करने के लिए Stop दबाएँ।", + "label.standard_fw_4": "4. Menu → Stop दबाएँ और चैनल 1 माप मेनू खुला छोड़ें।", + "label.standard_fw_5": "5. एप्लिकेशन में, सीरियल पोर्ट चुनें और Listen पर क्लिक करें।", + "label.standard_fw_6": "6. डीबग डेटा ट्रांसमिट करने के लिए ऑसिलोस्कोप पर Save बटन लॉन्ग-प्रेस करें।", + "label.standard_fw_7": "7. ट्रांसफ़र पूरा होने तक प्रतीक्षा करें। ऑसिलोस्कोप 115200 बॉड पर लगभग 300 kB भेजता है।", + "label.customized_fw_title": "अनुकूलित फ़र्मवेयर", + "label.customized_fw_1": "1. USB Type-C केबल से ऑसिलोस्कोप को कंप्यूटर से कनेक्ट करें।", + "label.customized_fw_2": "2. एप्लिकेशन में, पोर्ट चुनें और Listen पर क्लिक करें।", + "label.customized_fw_3": "3. ऑसिलोस्कोप पर Save दबाकर वेवफ़ॉर्म कैप्चर करें।", + "label.customized_fw_4": "4. सहेजे गए स्क्रीनशॉट खोलने के लिए Menu → Gallery दबाएँ।", + "label.customized_fw_5": "5. ट्रांसमिट करने के लिए वेवफ़ॉर्म चुनें।", + "label.customized_fw_6": "6. ऑसिलोस्कोप स्वचालित रूप से USB सीरियल पर 2048-बाइट बाइनरी पैकेट भेजता है।", + "label.customized_fw_7": "7. पैकेट में वेवफ़ॉर्म डेटा और मेटाडेटा शामिल है: V/डिव, टाइमबेस, ट्रिगर सेटिंग्स, कर्सर और माप।", + "label.using_viewer_title": "वेवफ़ॉर्म दर्शक का उपयोग", + "label.using_viewer_1": "एप्लिकेशन दोनों समर्थित ट्रांसमिशन मोड में ऑसिलोस्कोप डेटा प्राप्त कर सकता है।", + "label.using_viewer_2": "वेवफ़ॉर्म प्राप्त करने के बाद, आप इसका निरीक्षण कर सकते हैं, कर्सर से वोल्टेज और समय माप सकते हैं, और परिणाम PNG या CSV में निर्यात कर सकते हैं।", + "label.using_viewer_3": "डेटा प्राप्त करने से पहले, USB Type-C केबल से ऑसिलोस्कोप कनेक्ट करें, सही पोर्ट चुनें और Listen पर क्लिक करें।", + "label.firmware_title": "फ़र्मवेयर अपडेट", + "label.firmware_warning": "⚠ चेतावनी: गलत फ़र्मवेयर डिवाइस को खराब कर सकता है। सुनिश्चित करें कि डिवाइस बूट मोड में है और सही फ़ाइल चुनी गई है।", + "label.boot_title": "बूट मोड में स्विच करना (DSO3D12)", + "label.boot_1": "1. ऑसिलोस्कोप बंद होने पर USB केबल को PC से कनेक्ट करें।", + "label.boot_2": "2. पावर बटन दबाकर रखें। ऑसिलोस्कोप साइकिल में रीस्टार्ट हो सकता है — 100% फ़्लैश होने तक बटन दबाए रखें।", + "label.port": "पोर्ट:", + "label.firmware_file": "फ़र्मवेयर फ़ाइल (.fls):", + "label.no_file": "कोई फ़ाइल नहीं चुनी गई", + "label.firmware_status_ready": "फ़र्मवेयर फ़ाइल और पोर्ट चुनें, फिर अपडेट पर क्लिक करें।", + "label.firmware_confirm": "⚠ क्या आप सुनिश्चित हैं? गलत फ़र्मवेयर डिवाइस को खराब कर सकता है। पुष्टि के लिए फिर से अपडेट पर क्लिक करें।", + "label.firmware_no_file": "कोई फ़र्मवेयर फ़ाइल नहीं चुनी गई।", + "label.firmware_no_port": "कोई पोर्ट नहीं चुना गया।", + "label.firmware_starting": "शुरू हो रहा है…", + "label.firmware_complete": "फ़्लैश पूर्ण! आप इस डायलॉग को बंद कर सकते हैं।", + "label.firmware_error": "फ़्लैश त्रुटि: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} ब्लॉक)", + "label.no_data": "कोई डेटा नहीं", + "label.all_files": "सभी फ़ाइलें", + "label.zwcap_filter": "ZeeWeii कैप्चर", + "label.csv_filter": "CSV", + "label.png_filter": "PNG इमेज", + "label.fls_filter": "फ़र्मवेयर", + "label.freq_name": "आवृत्ति", + "label.pkpk_name": "पीक-पीक", + "label.avg_name": "औसत", + "label.rms_name": "RMS", + "label.amp_name": "आयाम", + "label.duty_plus_name": "+ड्यूटी", + "label.duty_minus_name": "-ड्यूटी", + "label.t_plus_name": "+T", + "label.t_minus_name": "-T", + "label.t_name": "T", + "label.max_name": "अधिकतम", + "label.min_name": "न्यूनतम", + "label.top_name": "शीर्ष", + "label.base_name": "आधार", + "label.cursor_x_range": "कर्सर X (समय)", + "label.cursor_y_range": "कर्सर Y (वोल्टेज)", + "label.ctrl_fine_drag": "बारीक खींचने के लिए CTRL दबाएँ", + "label.view_mode_line": "रेखा", + "label.view_mode_dot": "बिंदु", + "label.view_mode_smooth": "चिकना" +} diff --git a/gui/locales/ru.json b/gui/locales/ru.json new file mode 100644 index 0000000..e8e1207 --- /dev/null +++ b/gui/locales/ru.json @@ -0,0 +1,155 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "Готов.", + "app.settings_saved": "Настройки сохранены.", + "app.no_port": "Не выбран порт", + "app.no_capture": "Нет данных для экспорта.", + "app.no_capture_loaded": "Не загружен сигнал — используйте «кнопку Загрузить» или «Слушать»…", + "app.select_port": "Сначала выберите последовательный порт.", + "btn.help": "Справка", + "btn.stop": "Стоп", + "btn.listen": "Слушать", + "btn.close": "Закрыть", + "btn.save": "Сохранить и закрыть", + "btn.update": "Обновить", + "btn.confirm_update": "Подтвердить обновление", + "btn.updating": "Обновление…", + "btn.browse": "Обзор…", + "btn.refresh": "Обновить", + "btn.load_capture": "Загрузить", + "btn.save_capture": "Сохранить", + "btn.save_png": "Сохранить PNG", + "btn.export_data": "Экспорт данных", + "btn.settings": "Настройки", + "btn.help_open": "Справка", + "btn.firmware_update": "Обновление прошивки…", + "btn.undo_delete": "Отменить удаление", + "btn.export_png": "Экспорт PNG…", + "btn.export_csv": "Экспорт CSV…", + "btn.delete": "Удалить", + "label.measurement": "Измерения", + "label.cursor_x": "Курсор X (время)", + "label.cursor_y": "Курсор Y (напряжение)", + "label.freq": "≈ частота: %{hz}, Гц", + "label.dt": "Δt = %{ms} мс", + "label.ch1_dv": "CH1 ΔV = %{v} В", + "label.ch2_dv": "CH2 ΔV = %{v} В", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "В/дел, В:", + "label.probe": "Множитель:", + "label.voff": "Смещ, В:", + "label.tdiv": "время/дел, мс:", + "label.ac_mode": "Режим АС: 8×В, 12×t дел", + "label.time_range": "Время: %{ms} мс (%{n} отсчётов)", + "label.ch1_stats": "CH1: n=%{n} мин=%{min} макс=%{max} ср=%{avg}", + "label.ch2_stats": "CH2: n=%{n} мин=%{min} макс=%{max} ср=%{avg}", + "label.trigger": "Синхронизация", + "label.trigger_info": "Режим: %{mode} | Фронт: %{edge} | Ист: %{src}", + "label.trigger_level": "Уровень: %{v} В", + "label.ch1_probe": "CH1: множ. %{probe}, %{coupling}", + "label.ch2_probe": "CH2: множ. %{probe}, %{coupling}", + "label.delay_rate": "Задержка: %{delay} с | Частота: %{rate} выборок/с", + "label.auto_meas": "Автоизмерения", + "label.device_x": "Курсор X", + "label.device_y": "Курсор Y", + "label.scales": "Шкалы", + "label.view": "Вид:", + "label.split_ch": "Разделить CH1/CH2", + "label.show_instructions": "Показать инструкции", + "label.listening_on": "Слушаю %{port}…", + "label.stopped": "Остановлено.", + "label.connected_to": "Подключено к %{port}.", + "label.disconnected": "Порт отключён; переподключение…", + "label.receiving": "Получение… %{n} байт", + "label.screenshot_received": "Скриншот получен (CH1: %{ch1}/дел, CH2: %{ch2}/дел, %{timebase}/дел). Всего: %{total}", + "label.received_captures": "Получено %{count} захватов. Всего: %{total}", + "label.saved_ok": "Сохранено %{n} захвата(ов) в %{path}", + "label.save_error": "Ошибка сохранения: %{e}", + "label.png_saved": "PNG сохранён в %{path}", + "label.png_error": "Ошибка PNG: %{e}", + "label.csv_written": "Записан %{path}", + "label.csv_error": "Ошибка CSV: %{e}", + "label.loaded_ok": "Загружено %{n} захвата(ов) из %{path}", + "label.load_error": "Ошибка загрузки: %{e}", + "label.deleted": "Удалён захват #%{n}. Нажмите «Отменить удаление» для восстановления.", + "label.restored": "Восстановлен захват #%{n}.", + "label.undo_expired": "Отмена недоступна.", + "label.settings_title": "Настройки", + "label.serial_section": "Последовательный порт", + "label.default_port": "Порт по умолчанию:", + "label.available_ports": "Доступны: %{ports}", + "label.no_ports": "Последовательные порты не обнаружены.", + "label.baud_rate": "Скорость (бод):", + "label.auto_listen": "Авто-прослушивание при запуске", + "label.beep_on_capture": "Звуковой сигнал при захвате", + "label.display_section": "Экран", + "label.language": "Язык", + "label.png_width": "Ширина PNG, px (0=мал.):", + "label.about_section": "О программе", + "label.repository": "Репозиторий:", + "label.config_path": "Конфиг: ", + "label.instructions_title": "Инструкции", + "label.standard_fw_title": "Стандартная прошивка — дамп данных", + "label.standard_fw_1": "1. Подключите осциллограф к компьютеру через USB Type-C.", + "label.standard_fw_2": "2. Подайте сигнал на осциллограф.", + "label.standard_fw_3": "3. Нажмите Stop для заморозки буфера.", + "label.standard_fw_4": "4. Нажмите Menu → Stop и оставьте меню измерений канала 1 открытым.", + "label.standard_fw_5": "5. В приложении выберите порт и нажмите «Слушать».", + "label.standard_fw_6": "6. Долго нажмите Save на осциллографе для передачи отладочных данных.", + "label.standard_fw_7": "7. Дождитесь завершения передачи (~300 кБ при 115200 бод).", + "label.customized_fw_title": "Модифицированная прошивка", + "label.customized_fw_1": "1. Подключите осциллограф к компьютеру через USB Type-C.", + "label.customized_fw_2": "2. В приложении выберите порт и нажмите «Слушать».", + "label.customized_fw_3": "3. Захватите сигнал на осциллографе, нажав Save во время работы или остановки.", + "label.customized_fw_4": "4. Нажмите Menu → Gallery для открытия сохранённых скриншотов.", + "label.customized_fw_5": "5. Выберите сигнал для передачи.", + "label.customized_fw_6": "6. Осциллограф автоматически отправит пакет 2048 байт через USB.", + "label.customized_fw_7": "7. Пакет включает данные сигнала и метаданные: В/дел, время, синхронизацию, курсоры и измерения.", + "label.using_viewer_title": "Использование просмотрщика", + "label.using_viewer_1": "Приложение может получать данные осциллографа в обоих поддерживаемых режимах.", + "label.using_viewer_2": "После получения сигнала вы можете просмотреть его, измерить напряжение и время с помощью курсоров и экспортировать результат в PNG или CSV.", + "label.using_viewer_3": "Перед получением данных подключите осциллограф через USB Type-C, выберите порт и нажмите «Слушать».", + "label.firmware_title": "Обновление прошивки", + "label.firmware_warning": "⚠ ВНИМАНИЕ: Неправильная прошивка может повредить устройство. Убедитесь, что устройство в режиме загрузки и выбран правильный файл.", + "label.boot_title": "Переход в режим загрузки (DSO3D12)", + "label.boot_1": "1. Подключите USB-кабель к ПК при выключенном осциллографе.", + "label.boot_2": "2. Нажмите и удерживайте кнопку питания. Осциллограф может циклически перезагружаться — продолжайте удерживать кнопку до завершения прошивки (100%).", + "label.port": "Порт:", + "label.firmware_file": "Файл прошивки (.fls):", + "label.no_file": "Файл не выбран", + "label.firmware_status_ready": "Выберите файл прошивки и порт, затем нажмите «Обновить».", + "label.firmware_confirm": "⚠ Вы уверены? Неправильная прошивка может вывести устройство из строя. Нажмите «Обновить» ещё раз для подтверждения.", + "label.firmware_no_file": "Файл прошивки не выбран.", + "label.firmware_no_port": "Порт не выбран.", + "label.firmware_starting": "Запуск…", + "label.firmware_complete": "Прошивка завершена! Вы можете закрыть этот диалог.", + "label.firmware_error": "Ошибка прошивки: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} блоков)", + "label.no_data": "Нет данных", + "label.all_files": "Все файлы", + "label.zwcap_filter": "ZeeWeii захват", + "label.csv_filter": "CSV", + "label.png_filter": "PNG изображение", + "label.fls_filter": "Прошивка", + "label.freq_name": "Частота", + "label.pkpk_name": "Размах", + "label.avg_name": "Среднее", + "label.rms_name": "RMS", + "label.amp_name": "Ампл", + "label.duty_plus_name": "+Заполн", + "label.duty_minus_name": "-Заполн", + "label.t_plus_name": "+Имп", + "label.t_minus_name": "-Имп", + "label.t_name": "Пер", + "label.max_name": "Макс", + "label.min_name": "Мин", + "label.top_name": "Верх", + "label.base_name": "Осн", + "label.cursor_x_range": "Курсор X (время)", + "label.cursor_y_range": "Курсор Y (напряжение)", + "label.ctrl_fine_drag": "Зажмите CTRL для точного перемещения", + "label.view_mode_line": "Линия", + "label.view_mode_dot": "Точки", + "label.view_mode_smooth": "Сглаженный" +} diff --git a/gui/locales/zh.json b/gui/locales/zh.json new file mode 100644 index 0000000..a78445e --- /dev/null +++ b/gui/locales/zh.json @@ -0,0 +1,155 @@ +{ + "app.title": "DSO3D12 Viewer", + "app.ready": "就绪。", + "app.settings_saved": "设置已保存。", + "app.no_port": "无端口", + "app.no_capture": "没有可导出的捕获。", + "app.no_capture_loaded": "信号未加载 - 请使用‘下载’或‘收听’按钮……", + "app.select_port": "请先选择串口。", + "btn.help": "帮助", + "btn.stop": "停止", + "btn.listen": "监听", + "btn.close": "关闭", + "btn.save": "保存并关闭", + "btn.update": "更新", + "btn.confirm_update": "确认更新", + "btn.updating": "正在更新…", + "btn.browse": "浏览…", + "btn.refresh": "刷新", + "btn.load_capture": "加载捕获", + "btn.save_capture": "保存捕获", + "btn.save_png": "保存PNG", + "btn.export_data": "导出数据", + "btn.settings": "设置", + "btn.help_open": "帮助", + "btn.firmware_update": "固件更新…", + "btn.undo_delete": "撤销删除", + "btn.export_png": "导出PNG…", + "btn.export_csv": "导出CSV…", + "btn.delete": "删除", + "label.measurement": "测量", + "label.cursor_x": "光标X (时间)", + "label.cursor_y": "光标Y (电压)", + "label.freq": "≈ 频率: %{hz}", + "label.dt": "Δt = %{ms} 毫秒", + "label.ch1_dv": "CH1 ΔV = %{v} V", + "label.ch2_dv": "CH2 ΔV = %{v} V", + "label.ch1": "CH1", + "label.ch2": "CH2", + "label.vdiv": "V/格, V:", + "label.probe": "倍数:", + "label.voff": "偏置, V:", + "label.tdiv": "时基, 毫秒:", + "label.ac_mode": "交流模式: 8×V, 12×t 格", + "label.time_range": "时间: %{ms} 毫秒 (%{n} 采样)", + "label.ch1_stats": "CH1: n=%{n} 最小值=%{min} 最大值=%{max} 平均值=%{avg}", + "label.ch2_stats": "CH2: n=%{n} 最小值=%{min} 最大值=%{max} 平均值=%{avg}", + "label.trigger": "触发", + "label.trigger_info": "模式: %{mode} | 边沿: %{edge} | 源: %{src}", + "label.trigger_level": "电平: %{v} V", + "label.ch1_probe": "CH1: 倍数 %{probe}, %{coupling}", + "label.ch2_probe": "CH2: 倍数 %{probe}, %{coupling}", + "label.delay_rate": "延迟: %{delay} 秒 | 采样率: %{rate} Sa/s", + "label.auto_meas": "自动测量", + "label.device_x": "设备光标X", + "label.device_y": "设备光标Y", + "label.scales": "刻度", + "label.view": "视图:", + "label.split_ch": "分离CH1/CH2", + "label.show_instructions": "显示说明", + "label.listening_on": "正在监听 %{port}…", + "label.stopped": "已停止。", + "label.connected_to": "已连接到 %{port}。", + "label.disconnected": "端口已断开; 正在重新连接…", + "label.receiving": "正在接收… %{n} 字节", + "label.screenshot_received": "已接收截图 (CH1: %{ch1}/格, CH2: %{ch2}/格, %{timebase}/格)。总计: %{total}", + "label.received_captures": "已接收 %{count} 个捕获。总计: %{total}", + "label.saved_ok": "已保存 %{n} 个捕获到 %{path}", + "label.save_error": "保存错误: %{e}", + "label.png_saved": "PNG 已保存到 %{path}", + "label.png_error": "PNG 错误: %{e}", + "label.csv_written": "已写入 %{path}", + "label.csv_error": "CSV 错误: %{e}", + "label.loaded_ok": "已从 %{path} 加载 %{n} 个捕获", + "label.load_error": "加载错误: %{e}", + "label.deleted": "已删除捕获 #%{n}。按撤销删除恢复。", + "label.restored": "已恢复捕获 #%{n}。", + "label.undo_expired": "撤销已过期。", + "label.settings_title": "设置", + "label.serial_section": "串口", + "label.default_port": "默认端口:", + "label.available_ports": "可用端口: %{ports}", + "label.no_ports": "未检测到串口。", + "label.baud_rate": "波特率:", + "label.auto_listen": "启动时自动监听", + "label.beep_on_capture": "新捕获时发出提示音", + "label.display_section": "显示", + "label.language": "语言", + "label.png_width": "PNG宽度,像素(0=小):", + "label.about_section": "关于", + "label.repository": "仓库:", + "label.config_path": "配置路径: ", + "label.instructions_title": "使用说明", + "label.standard_fw_title": "标准固件 — 原始数据转储", + "label.standard_fw_1": "1. 使用 USB Type-C 线将示波器连接到计算机。", + "label.standard_fw_2": "2. 向示波器输入信号。", + "label.standard_fw_3": "3. 按 Stop 冻结采样缓冲区。", + "label.standard_fw_4": "4. 按 Menu → Stop 并保持通道1测量菜单打开。", + "label.standard_fw_5": "5. 在应用程序中,选择串口并点击监听。", + "label.standard_fw_6": "6. 长按示波器上的 Save 按钮开始传输调试数据。", + "label.standard_fw_7": "7. 等待传输完成。示波器在115200波特率下发送约300 kB数据。", + "label.customized_fw_title": "定制固件", + "label.customized_fw_1": "1. 使用 USB Type-C 线将示波器连接到计算机。", + "label.customized_fw_2": "2. 在应用程序中,选择串口并点击监听。", + "label.customized_fw_3": "3. 在示波器上按 Save 捕获波形(运行或停止状态均可)。", + "label.customized_fw_4": "4. 按 Menu → Gallery 打开保存的截图列表。", + "label.customized_fw_5": "5. 选择要传输的波形。", + "label.customized_fw_6": "6. 示波器自动通过 USB 串口发送 2048 字节的二进制数据包。", + "label.customized_fw_7": "7. 数据包包含波形数据和元数据,如 V/格、时基、触发设置、光标和测量值。", + "label.using_viewer_title": "使用波形查看器", + "label.using_viewer_1": "应用程序可以接收两种支持传输模式下的示波器数据。", + "label.using_viewer_2": "接收波形后,您可以检查它,使用光标测量电压和时间间隔,并将结果导出为 PNG 图像或 CSV 数据。", + "label.using_viewer_3": "接收数据前,使用 USB Type-C 线连接示波器,选择正确的串口,然后点击监听。", + "label.firmware_title": "固件更新", + "label.firmware_warning": "⚠ 警告: 错误的固件可能导致设备损坏。请确保设备处于启动模式且选择了正确的固件文件。", + "label.boot_title": "切换到启动模式 (DSO3D12)", + "label.boot_1": "1. 示波器关机状态下,将 USB 线连接到电脑。", + "label.boot_2": "2. 按住电源按钮。示波器可能进入循环重启状态 — 请持续按住按钮直到刷写达到100%。", + "label.port": "端口:", + "label.firmware_file": "固件文件 (.fls):", + "label.no_file": "未选择文件", + "label.firmware_status_ready": "选择固件文件和端口,然后点击更新。", + "label.firmware_confirm": "⚠ 您确定吗?错误的固件可能导致设备损坏。再次点击更新以确认。", + "label.firmware_no_file": "未选择固件文件。", + "label.firmware_no_port": "未选择端口。", + "label.firmware_starting": "正在启动…", + "label.firmware_complete": "刷写完成!您可以关闭此对话框。", + "label.firmware_error": "刷写错误: %{e}", + "label.pct_progress": "%{pct}% (%{cur}/%{total} 块)", + "label.no_data": "无数据", + "label.all_files": "所有文件", + "label.zwcap_filter": "ZeeWeii 捕获", + "label.csv_filter": "CSV", + "label.png_filter": "PNG 图像", + "label.fls_filter": "固件", + "label.freq_name": "频率", + "label.pkpk_name": "峰峰值", + "label.avg_name": "平均值", + "label.rms_name": "有效值", + "label.amp_name": "幅度", + "label.duty_plus_name": "+占空比", + "label.duty_minus_name": "-占空比", + "label.t_plus_name": "+脉宽", + "label.t_minus_name": "-脉宽", + "label.t_name": "周期", + "label.max_name": "最大值", + "label.min_name": "最小值", + "label.top_name": "顶端", + "label.base_name": "底端", + "label.cursor_x_range": "光标X (时间)", + "label.cursor_y_range": "光标Y (电压)", + "label.ctrl_fine_drag": "按住CTRL进行精细拖动", + "label.view_mode_line": "连线", + "label.view_mode_dot": "散点", + "label.view_mode_smooth": "平滑" +} diff --git a/gui/src/app.rs b/gui/src/app.rs new file mode 100644 index 0000000..80e1883 --- /dev/null +++ b/gui/src/app.rs @@ -0,0 +1,2605 @@ +//! Application state, messages, update and view (iced 0.14). + +use std::path::PathBuf; + +use chrono::{DateTime, Local}; +use dso_parser::{ADC_VALUE_MID, Capture, DEFAULT_BUFFER_DEPTH}; +#[cfg(test)] +use dso_parser::ADC_VALUE_MAX; +use iced::widget::{ + button, canvas, checkbox, column, container, pick_list, progress_bar, row, rule, + scrollable, text, text_input, Space, +}; +use iced::{ + event, + keyboard, + mouse, + Color, Event, + Element, Length, Subscription, Task, Theme, +}; + +use crate::canvas::{Scope, Thumbnail}; +use crate::export_image::export_png; +use crate::i18n::t; +use crate::range_slider; +use crate::serial::{self, SerialConfig, SerialEvent, SerialHandle}; +use crate::settings::{Settings, ViewMode}; + +// ── Messages ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum Message { + // File actions + LoadClicked, + FilePicked(Option), + Loaded(Result), + SaveCaptureClicked, + SaveCapturePicked(Option), + SavePngClicked, + PngPathPicked(Option), + ExportCsvClicked, + ExportCsvPicked(Option), + // Capture selection + SelectCapture(usize), + // Serial port + PortSelected(String), + StartListening, + StopListening, + SerialEvent(#[allow(dead_code)] SerialEvent), + // Channel & view (below-graph control panel) + ToggleCh1(bool), + ToggleCh2(bool), + ToggleDeviceCursorX(bool), + ToggleDeviceCursorY(bool), + ToggleShowScales(bool), + ToggleSplitPng(bool), + ViewModeSelected(ViewMode), + // Measurement cursors (right panel) + ToggleMeasCursorX(bool), + ToggleMeasCursorY(bool), + MeasCursorXChanged(f32, f32), + ModifiersChanged(keyboard::Modifiers), + LeftMouseReleased, + MeasCursorYChanged(f32, f32), + // Scale inputs (V/div, time/div, V offset) + VPerCellChangedCh1(String), + VPerCellChangedCh2(String), + TPerCellChanged(String), + VOffsetChangedCh1(String), + VOffsetChangedCh2(String), + // Graph click — move nearest cursor + GraphClicked(f32, f32), + // Misc + OpenInstructions, + CloseInstructions, + ToggleAlertOnData(bool), + ToggleAutoListen(bool), + // Settings dialog + OpenSettings, + CloseSettings, + SettingsBaudChanged(String), + SettingsPortChanged(String), + SettingsPngWidthChanged(String), + // Firmware dialog + OpenFirmware, + CloseFirmware, + FirmwarePortSelected(String), + FirmwareRefreshPorts, + FirmwareFileClicked, + FirmwareFilePicked(Option), + FirmwareConfirmStart, + FirmwareStart, + FirmwareEvent(#[allow(dead_code)] crate::flash::FlashEvent), + // Language selection + ChangeLanguage(String), + // Port scan tick (auto-refresh) + PortScanTick, + // Window resized + WindowResized(f32, f32), + // Open a URL in the default browser + OpenUrl(String), + // Context menu on thumbnails (right-click via iced_aw::ContextMenu) + ContextMenuDeleteAt(usize), + UndoDelete, + UndoExpiredTick, + ContextMenuExportPngAt(usize), + ContextMenuExportPngPicked(Option), + ContextMenuExportCsvAt(usize), + ContextMenuExportCsvPicked(Option), +} + +/// A capture paired with the timestamp of when it was received/loaded. +/// Holds both the legacy `Capture` (for rendering) and the rich `CaptureRecord` +/// (for metadata, measurements, and serialization). +#[derive(Debug, Clone)] +pub struct CaptureEntry { + pub capture: Capture, + pub timestamp: DateTime, + /// Full record with metadata (always present for new captures; may be + /// synthesized from legacy data when loading old files). + pub record: dso_parser::CaptureRecord, +} + +#[derive(Debug, Clone)] +pub struct LoadedDump { + pub path: PathBuf, + pub captures: Vec, +} + +/// Wrapper for language display in pick_list. +#[derive(Debug, Clone, PartialEq, Eq)] +struct LangOption { + code: String, + display: String, +} + +impl std::fmt::Display for LangOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.display) + } +} + +/// Factor applied to cursor movement when Ctrl is held (fine-drag mode). +const FINE_DRAG_FACTOR: f32 = 0.1; + +/// Anchor state for fine-dragging a measurement cursor range. +/// Stores the logical and raw slider values at the moment Ctrl+drag began, +/// so that only 1/10 of the raw delta is applied to the logical value. +#[derive(Debug, Clone, Copy)] +struct FineRangeDrag { + /// Slider values when the Ctrl-drag began. + logical_start: (f32, f32), + /// First raw values emitted by the range slider. + raw_start: (f32, f32), +} + +// ── State ────────────────────────────────────────────────────────────────── + +pub struct App { + pub(crate) settings: Settings, + pub(crate) dump: Option, + pub(crate) current: usize, + pub(crate) status: String, + pub(crate) available_ports: Vec, + pub(crate) listening: bool, + serial_handle: Option, + serial_rx: Option>, + /// Progress bytes received so far during a live capture. + capture_progress: usize, + /// Settings dialog open flag. + show_settings: bool, + /// Instructions overlay open flag. + show_instructions: bool, + /// Editable fields in settings dialog. + settings_baud_input: String, + settings_port_input: String, + /// Editable PNG export width in settings dialog. + png_width_input: String, + /// Firmware dialog state. + show_firmware: bool, + firmware_port: String, + firmware_file: Option, + firmware_progress: Option<(u64, u64)>, + firmware_status: String, + firmware_handle: Option, + firmware_rx: Option>, + firmware_confirming: bool, + /// Whether we were listening before firmware update (to auto-reconnect after). + was_listening_before_flash: bool, + /// Target capture index for context menu export operations. + context_export_idx: Option, + /// Last deleted capture, available for undo (index it was at, entry). + deleted_capture: Option<(usize, CaptureEntry)>, + /// Timestamp of the last deletion, for 30-second undo expiry. + deleted_at: Option, + ctrl_pressed: bool, + measurement_cursor_x_fine_drag: Option, + measurement_cursor_y_fine_drag: Option, + /// Text input state for V/div scale (CH1). + v_per_div_ch1_input: String, + /// Text input state for V/div scale (CH2). + v_per_div_ch2_input: String, + /// Text input state for t/div scale. + t_per_div_input: String, + /// Text input state for V/offset (CH1). + v_offset_ch1_input: String, + /// Text input state for V/offset (CH2). + v_offset_ch2_input: String, +} + +impl App { + pub fn new() -> (Self, Task) { + let settings = Settings::load(); + let ports = serial::list_ports(); + log::info!( + "starting GUI; persisted port = {:?}; available ports = {:?}", + settings.serial_port, + ports + ); + let baud_str = settings.baud_rate.to_string(); + let port_str = settings.serial_port.clone().unwrap_or_default(); + let v_per_div_ch1_str = format_float(settings.v_per_div_ch1); + let v_per_div_ch2_str = format_float(settings.v_per_div_ch2); + let t_per_div_str = format_float(settings.t_per_div_ms); + let v_offset_ch1_str = format_float(settings.v_offset_ch1); + let v_offset_ch2_str = format_float(settings.v_offset_ch2); + let png_width_str = settings.png_export_width.to_string(); + ( + Self { + settings, + dump: None, + current: 0, + status: t!("app.ready").to_string(), + available_ports: ports, + listening: false, + serial_handle: None, + serial_rx: None, + capture_progress: 0, + show_settings: false, + show_instructions: false, + settings_baud_input: baud_str, + settings_port_input: port_str, + png_width_input: png_width_str, + show_firmware: false, + firmware_port: String::new(), + firmware_file: None, + firmware_progress: None, + firmware_status: String::new(), + firmware_handle: None, + firmware_rx: None, + firmware_confirming: false, + was_listening_before_flash: false, + context_export_idx: None, + deleted_capture: None, + deleted_at: None, + ctrl_pressed: false, + measurement_cursor_x_fine_drag: None, + measurement_cursor_y_fine_drag: None, + v_per_div_ch1_input: v_per_div_ch1_str, + v_per_div_ch2_input: v_per_div_ch2_str, + t_per_div_input: t_per_div_str, + v_offset_ch1_input: v_offset_ch1_str, + v_offset_ch2_input: v_offset_ch2_str, + }, + Task::none(), + ) + } + + pub fn title(&self) -> String { + let ver = env!("CARGO_PKG_VERSION"); + match &self.dump { + Some(d) => format!( + "{} v{ver} — {} ({} {})", + t!("app.title"), + d.path.file_name().and_then(|s| s.to_str()).unwrap_or("?"), + d.captures.len(), + if d.captures.len() == 1 { "capture" } else { "captures" } + ), + None => format!("{} v{ver}", t!("app.title")), + } + } + + pub fn theme(&self) -> Theme { + Theme::Dark + } + + pub(crate) fn current_capture(&self) -> Option<&Capture> { + self.dump + .as_ref() + .and_then(|d| d.captures.get(self.current)) + .map(|e| &e.capture) + } + + pub(crate) fn current_entry(&self) -> Option<&CaptureEntry> { + self.dump + .as_ref() + .and_then(|d| d.captures.get(self.current)) + } + + pub(crate) fn capture_has_device_cursor_x(&self) -> bool { + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .map(|s| s.cursors_x_enable) + .unwrap_or(false) + } + pub(crate) fn capture_has_device_cursor_y(&self) -> bool { + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .map(|s| s.cursors_y_enable) + .unwrap_or(false) + } + + // ── Subscription for serial events ───────────────────────────────── + + pub fn subscription(&self) -> Subscription { + let mut subs = vec![]; + + // Poll serial receiver + if self.listening && self.serial_rx.is_some() { + subs.push( + iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::SerialEvent(SerialEvent::Progress(0))), + ); + } + + // Auto port scan every 10s + subs.push( + iced::time::every(std::time::Duration::from_secs(10)) + .map(|_| Message::PortScanTick), + ); + + // Track window resize to persist size + subs.push( + iced::window::resize_events() + .map(|(_id, size)| Message::WindowResized(size.width, size.height)), + ); + + // Poll firmware events + if self.firmware_rx.is_some() { + subs.push( + iced::time::every(std::time::Duration::from_millis(100)) + .map(|_| Message::FirmwareEvent(crate::flash::FlashEvent::Status(String::new()))), + ); + } + + // Expire undo buffer after 30 seconds + if self.deleted_capture.is_some() { + subs.push( + iced::time::every(std::time::Duration::from_secs(1)) + .map(|_| Message::UndoExpiredTick), + ); + } + + // Keyboard modifier and mouse-release events for fine-drag + subs.push(event::listen_with(|event, _status, _window| match event { + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + Some(Message::ModifiersChanged(modifiers)) + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + Some(Message::LeftMouseReleased) + } + _ => None, + })); + + Subscription::batch(subs) + } + + // ── Update ───────────────────────────────────────────────────────── + + pub fn update(&mut self, msg: Message) -> Task { + match msg { + Message::LoadClicked => Task::perform(pick_file(), Message::FilePicked), + Message::FilePicked(None) => Task::none(), + Message::FilePicked(Some(path)) => Task::perform(load_dump(path), |r| { + Message::Loaded(r.map_err(|e| e.to_string())) + }), + Message::Loaded(Ok(d)) => { + self.deleted_capture = None; + self.deleted_at = None; + let n = d.captures.len(); + let path_s = d.path.display().to_string(); + self.status = t!("label.loaded_ok", n = n, path = path_s).to_string(); + log::info!("{}", self.status); + self.current = 0; + self.dump = Some(d); + // Auto-populate from the first record's scope_state only if it + // is the record being shown (single-capture files). For multi- + // capture files the user must select a record to apply its + // scope_state via the thumbnail strip. + if self.dump.as_ref().map(|d| d.captures.len() <= 1).unwrap_or(false) { + if let Some(state) = self.current_entry() + .and_then(|e| e.record.scope_state.clone()) + { + self.apply_scope_state(&state); + } + } + Task::none() + } + Message::Loaded(Err(e)) => { + self.status = t!("label.load_error", e = format!("{e}")).to_string(); + log::error!("load error: {e}"); + Task::none() + } + Message::SaveCaptureClicked => { + Task::perform(pick_save_bin(), Message::SaveCapturePicked) + } + Message::SaveCapturePicked(None) => Task::none(), + Message::SaveCapturePicked(Some(path)) => { + if let Some(dump) = &self.dump { + match save_all_captures(&path, &dump.captures) { + Ok(()) => self.status = t!("label.saved_ok", n = dump.captures.len(), path = path.display().to_string()).to_string(), + Err(e) => { + self.status = t!("label.save_error", e = format!("{e}")).to_string(); + log::error!("save error: {e}"); + } + } + } + Task::none() + } + Message::SavePngClicked => { + let ts = self.current_entry().map(|e| e.timestamp); + Task::perform(pick_save_png(ts), Message::PngPathPicked) + } + Message::PngPathPicked(None) => Task::none(), + Message::PngPathPicked(Some(path)) => { + if let Some(cap) = self.current_capture().cloned() { + match export_png(&path, &cap, &self.settings) { + Ok(()) => self.status = t!("label.png_saved", path = path.display().to_string()).to_string(), + Err(e) => { + self.status = t!("label.png_error", e = format!("{e}")).to_string(); + log::error!("PNG error: {e}"); + } + } + } else { + self.status = t!("app.no_capture").to_string(); + } + Task::none() + }, + Message::ExportCsvClicked => { + let ts = self.current_entry().map(|e| e.timestamp); + Task::perform(pick_save_csv(ts), Message::ExportCsvPicked) + } + Message::ExportCsvPicked(None) => Task::none(), + Message::ExportCsvPicked(Some(path)) => { + if let Some(cap) = self.current_capture() { + match export_csv(&path, cap) { + Ok(()) => self.status = t!("label.csv_written", path = path.display().to_string()).to_string(), + Err(e) => { + self.status = t!("label.csv_error", e = format!("{e}")).to_string(); + log::error!("CSV error: {e}"); + } + } + } + Task::none() + } + Message::SelectCapture(i) => { + if let Some(d) = &self.dump { + if i < d.captures.len() { + self.current = i; + // Clone scope_state before calling apply_scope_state + // to avoid borrow conflict with self.dump. + let scope_state = d.captures[i].record.scope_state.clone(); + if let Some(ref state) = scope_state { + self.apply_scope_state(state); + } + } + } + Task::none() + } + Message::PortSelected(p) => { + log::info!("port selected: {p}"); + self.settings.serial_port = Some(p); + self.settings.save(); + // Refresh ports + self.available_ports = serial::list_ports(); + Task::none() + } + Message::StartListening => { + let port = match &self.settings.serial_port { + Some(p) if !p.is_empty() => p.clone(), + _ => { + self.status = t!("app.select_port").to_string(); + return Task::none(); + } + }; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let config = SerialConfig { + port, + baud_rate: self.settings.baud_rate, + }; + let handle = serial::start_listening(config, tx); + self.serial_handle = Some(handle); + self.serial_rx = Some(rx); + self.listening = true; + self.capture_progress = 0; + let port = self.settings.serial_port.as_deref().unwrap_or("?").to_string(); + self.status = t!("label.listening_on", port = port).to_string(); + Task::none() + } + Message::StopListening => { + if let Some(mut h) = self.serial_handle.take() { + h.stop(); + } + self.serial_rx = None; + self.listening = false; + self.status = t!("label.stopped").to_string(); + Task::none() + } + Message::SerialEvent(_) => { + // Drain all pending events from the channel. + if let Some(rx) = &mut self.serial_rx { + while let Ok(evt) = rx.try_recv() { + match evt { + SerialEvent::Connected => { + let port = self.settings.serial_port.as_deref().unwrap_or("?").to_string(); + self.status = t!("label.connected_to", port = port).to_string(); + log::info!("{}", self.status); + } + SerialEvent::Disconnected => { + self.status = t!("label.disconnected").to_string(); + log::warn!("{}", self.status); + } + SerialEvent::Progress(n) => { + if n > 0 { + // Beep when data first starts arriving + if self.capture_progress == 0 && self.settings.alert_on_data { + play_beep_start(); + } + self.capture_progress = n; + self.status = t!("label.receiving", n = n).to_string(); + } + } + SerialEvent::CaptureReceived(captures) => { + let count = captures.len(); + log::info!( + "received {count} capture(s) from serial, ch1 len = {}", + captures.first().map(|c| c.ch1.len()).unwrap_or(0) + ); + // Beep when capture is complete + if self.settings.alert_on_data { + play_beep_end(); + } + // Wrap captures with timestamps + let now = Local::now(); + let entries: Vec = captures + .into_iter() + .map(|capture| { + let record = dso_parser::capture_to_record(&capture, now, None); + CaptureEntry { capture, timestamp: now, record } + }) + .collect(); + // Append to existing dump rather than replacing. + if let Some(ref mut dump) = self.dump { + let prev_len = dump.captures.len(); + dump.captures.extend(entries); + // Auto-select the newly received capture. + self.current = prev_len; + } else { + self.dump = Some(LoadedDump { + path: PathBuf::from(""), + captures: entries, + }); + self.current = 0; + } + self.capture_progress = 0; + self.deleted_capture = None; + self.deleted_at = None; + let total = self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0); + self.status = t!("label.received_captures", count = count, total = total).to_string(); + } + SerialEvent::ScreenshotReceived(raw_bytes) => { + log::info!("received screenshot packet ({} bytes)", raw_bytes.len()); + if self.settings.alert_on_data { + play_beep_end(); + } + let now = Local::now(); + match dso_parser::parse_screenshot(&raw_bytes) { + Ok(pkt) => { + let record = dso_parser::screenshot_to_record(&pkt, now, &raw_bytes); + let capture = pkt.to_capture(); + // Auto-fill scale from screenshot metadata + let s = &pkt.settings; + let probe1 = 10f64.powi(s.ch1.probe_mode as i32); + let probe2 = 10f64.powi(s.ch2.probe_mode as i32); + self.settings.v_per_div_ch1 = s.ch1.volt_scale_uv as f64 / 1_000_000.0 * probe1; + self.settings.v_per_div_ch2 = s.ch2.volt_scale_uv as f64 / 1_000_000.0 * probe2; + self.settings.t_per_div_ms = s.timebase_ns_per_div as f64 / 1_000_000.0; + // pixel_to_volts at screen centre gives −zero_volt_pixels × vpp × mult, + // but zero_volt_uv stores the value with opposite sign → negate. + self.settings.v_offset_ch1 = -(s.ch1.zero_volt_uv as f64) / 1_000_000.0 * probe1; + self.settings.v_offset_ch2 = -(s.ch2.zero_volt_uv as f64) / 1_000_000.0 * probe2; + self.v_per_div_ch1_input = format_float(self.settings.v_per_div_ch1); + self.v_per_div_ch2_input = format_float(self.settings.v_per_div_ch2); + self.t_per_div_input = format_float(self.settings.t_per_div_ms); + self.v_offset_ch1_input = format_float(self.settings.v_offset_ch1); + self.v_offset_ch2_input = format_float(self.settings.v_offset_ch2); + self.settings.save(); + + let entry = CaptureEntry { capture, timestamp: now, record }; + if let Some(ref mut dump) = self.dump { + let prev_len = dump.captures.len(); + dump.captures.push(entry); + self.current = prev_len; + } else { + self.dump = Some(LoadedDump { + path: PathBuf::from(""), + captures: vec![entry], + }); + self.current = 0; + } + self.capture_progress = 0; + self.deleted_capture = None; + self.deleted_at = None; + let total = self.dump.as_ref().map(|d| d.captures.len()).unwrap_or(0); + self.status = t!("label.screenshot_received", + ch1 = dso_parser::format_uv((self.settings.v_per_div_ch1 * 1_000_000.0) as i64, 0), + ch2 = dso_parser::format_uv((self.settings.v_per_div_ch2 * 1_000_000.0) as i64, 0), + timebase = s.timebase_label(), + total = total, + ).to_string(); + } + Err(e) => { + self.status = t!("label.load_error", e = format!("{e}")).to_string(); + log::error!("screenshot parse: {e}"); + } + } + } + SerialEvent::Error(e) => { + self.status = format!("Serial: {e}"); + // Not translated — raw device error message + log::error!("serial error: {e}"); + } + } + } + } + Task::none() + } + Message::ToggleCh1(v) => { + self.settings.show_ch1 = v; + self.settings.save(); + Task::none() + } + Message::ToggleCh2(v) => { + self.settings.show_ch2 = v; + self.settings.save(); + Task::none() + } + Message::ToggleDeviceCursorX(v) => { + self.settings.device_cursor_x_enabled = v; + self.settings.save(); + Task::none() + } + Message::ToggleDeviceCursorY(v) => { + self.settings.device_cursor_y_enabled = v; + self.settings.save(); + Task::none() + } + Message::ViewModeSelected(m) => { + self.settings.view_mode = m; + self.settings.save(); + Task::none() + } + Message::ToggleMeasCursorX(v) => { + self.settings.measurement_cursor_x_enabled = v; + self.settings.save(); + Task::none() + } + Message::ToggleMeasCursorY(v) => { + self.settings.measurement_cursor_y_enabled = v; + self.settings.save(); + Task::none() + } + Message::MeasCursorXChanged(raw_lo, raw_hi) => { + let ctrl = self.ctrl_pressed; + let anchor = &mut self.measurement_cursor_x_fine_drag; + let range = &mut self.settings.cursor_x_range; + App::apply_fine_drag(ctrl, anchor, range, raw_lo, raw_hi); + Task::none() + } + Message::ModifiersChanged(modifiers) => { + self.ctrl_pressed = modifiers.control(); + if !self.ctrl_pressed { + self.measurement_cursor_x_fine_drag = None; + self.measurement_cursor_y_fine_drag = None; + } + Task::none() + } + Message::LeftMouseReleased => { + self.measurement_cursor_x_fine_drag = None; + self.measurement_cursor_y_fine_drag = None; + Task::none() + } + Message::MeasCursorYChanged(raw_lo, raw_hi) => { + let ctrl = self.ctrl_pressed; + let anchor = &mut self.measurement_cursor_y_fine_drag; + let range = &mut self.settings.cursor_y_range; + App::apply_fine_drag(ctrl, anchor, range, raw_lo, raw_hi); + Task::none() + } + Message::VPerCellChangedCh1(s) => { + self.v_per_div_ch1_input = s.clone(); + if let Ok(v) = s.parse::() { + if v > 0.0 && v.is_finite() { + self.settings.v_per_div_ch1 = v; + self.settings.save(); + } + } + Task::none() + } + Message::VPerCellChangedCh2(s) => { + self.v_per_div_ch2_input = s.clone(); + if let Ok(v) = s.parse::() { + if v > 0.0 && v.is_finite() { + self.settings.v_per_div_ch2 = v; + self.settings.save(); + } + } + Task::none() + } + Message::TPerCellChanged(s) => { + self.t_per_div_input = s.clone(); + if let Ok(v) = s.parse::() { + if v > 0.0 && v.is_finite() { + self.settings.t_per_div_ms = v; + self.settings.save(); + } + } + Task::none() + } + Message::VOffsetChangedCh1(s) => { + self.v_offset_ch1_input = s.clone(); + if let Ok(v) = s.parse::() { + if v.is_finite() { + self.settings.v_offset_ch1 = v; + self.settings.save(); + } + } + Task::none() + } + Message::VOffsetChangedCh2(s) => { + self.v_offset_ch2_input = s.clone(); + if let Ok(v) = s.parse::() { + if v.is_finite() { + self.settings.v_offset_ch2 = v; + self.settings.save(); + } + } + Task::none() + } + Message::GraphClicked(frac_x, frac_y) => { + // Move nearest cursor to the click position. + // If X cursors enabled, move nearest X cursor knob. + if self.settings.measurement_cursor_x_enabled { + let (lo, hi) = self.settings.cursor_x_range; + let dist_lo = (frac_x - lo).abs(); + let dist_hi = (frac_x - hi).abs(); + if dist_lo <= dist_hi { + self.settings.cursor_x_range.0 = frac_x.clamp(0.0, hi); + } else { + self.settings.cursor_x_range.1 = frac_x.clamp(lo, 1.0); + } + } + // If Y cursors enabled, move nearest Y cursor knob. + if self.settings.measurement_cursor_y_enabled { + let (lo, hi) = self.settings.cursor_y_range; + let dist_lo = (frac_y - lo).abs(); + let dist_hi = (frac_y - hi).abs(); + if dist_lo <= dist_hi { + self.settings.cursor_y_range.0 = frac_y.clamp(0.0, hi); + } else { + self.settings.cursor_y_range.1 = frac_y.clamp(lo, 1.0); + } + } + Task::none() + } + Message::OpenInstructions => { + self.show_instructions = true; + Task::none() + } + Message::CloseInstructions => { + self.show_instructions = false; + Task::none() + } + Message::OpenSettings => { + self.show_settings = true; + self.settings_baud_input = self.settings.baud_rate.to_string(); + self.settings_port_input = self.settings.serial_port.clone().unwrap_or_default(); + self.png_width_input = self.settings.png_export_width.to_string(); + Task::none() + } + Message::CloseSettings => { + // Apply settings + if let Ok(b) = self.settings_baud_input.parse::() { + if b > 0 { + self.settings.baud_rate = b; + } + } + let p = self.settings_port_input.trim().to_string(); + self.settings.serial_port = if p.is_empty() { None } else { Some(p) }; + // PNG export width: minimum 300 px, 0 = default small. + if let Ok(w) = self.png_width_input.trim().parse::() { + if w == 0 || w >= 300 { + self.settings.png_export_width = w; + } + } + self.settings.save(); + self.show_settings = false; + self.status = t!("app.settings_saved").to_string(); + Task::none() + } + Message::SettingsBaudChanged(s) => { + self.settings_baud_input = s; + Task::none() + } + Message::SettingsPortChanged(s) => { + self.settings_port_input = s; + Task::none() + } + Message::SettingsPngWidthChanged(s) => { + self.png_width_input = s; + Task::none() + } + Message::ToggleShowScales(v) => { + self.settings.show_scales = v; + self.settings.save(); + Task::none() + } + Message::ToggleSplitPng(v) => { + self.settings.split_png = v; + self.settings.save(); + Task::none() + } + Message::ToggleAlertOnData(v) => { + self.settings.alert_on_data = v; + self.settings.save(); + Task::none() + } + Message::ToggleAutoListen(v) => { + self.settings.auto_listen = v; + self.settings.save(); + Task::none() + } + Message::ChangeLanguage(l) => { + log::info!("language -> {l}"); + self.settings.language = l; + self.settings.save(); + crate::i18n::set_language(&self.settings.language); + Task::none() + } + Message::PortScanTick => { + self.available_ports = serial::list_ports(); + // Auto-listen: if enabled and not currently listening and a port is available + if self.settings.auto_listen && !self.listening { + if let Some(port) = &self.settings.serial_port { + if self.available_ports.contains(port) { + return self.update(Message::StartListening); + } + } + } + Task::none() + } + Message::WindowResized(w, h) => { + self.settings.window_size = (w, h); + self.settings.save(); + Task::none() + } + Message::OpenUrl(url) => { + let _ = open::that(url); + Task::none() + } + Message::ContextMenuDeleteAt(idx) => { + if let Some(ref mut dump) = self.dump { + if idx < dump.captures.len() { + let entry = dump.captures.remove(idx); + self.deleted_capture = Some((idx, entry)); + self.deleted_at = Some(std::time::Instant::now()); + if self.current >= dump.captures.len() && self.current > 0 { + self.current -= 1; + } + self.status = t!("label.deleted", n = idx + 1).to_string(); + } + } + Task::none() + } + Message::UndoDelete => { + self.deleted_at = None; + if let Some((idx, entry)) = self.deleted_capture.take() { + let dump = self.dump.get_or_insert_with(|| LoadedDump { + path: PathBuf::from(""), + captures: vec![], + }); + let insert_at = idx.min(dump.captures.len()); + dump.captures.insert(insert_at, entry); + self.current = insert_at; + self.status = t!("label.restored", n = insert_at + 1).to_string(); + } + Task::none() + } + Message::UndoExpiredTick => { + if let Some(at) = self.deleted_at { + if at.elapsed().as_secs() >= 30 { + self.deleted_capture = None; + self.deleted_at = None; + // Clear the status hint only if it still mentions undo + if self.status.contains("Undo") { + self.status = t!("label.undo_expired").to_string(); + } + } + } + Task::none() + } + Message::ContextMenuExportPngAt(idx) => { + let ts = self.dump.as_ref() + .and_then(|d| d.captures.get(idx)) + .map(|e| e.timestamp); + self.context_export_idx = Some(idx); + Task::perform(pick_save_png(ts), Message::ContextMenuExportPngPicked) + } + Message::ContextMenuExportPngPicked(None) => { + self.context_export_idx = None; + Task::none() + } + Message::ContextMenuExportPngPicked(Some(path)) => { + let idx = self.context_export_idx.take().unwrap_or(self.current); + if let Some(cap) = self.dump.as_ref().and_then(|d| d.captures.get(idx)).map(|e| &e.capture).cloned() { + match export_png(&path, &cap, &self.settings) { + Ok(()) => self.status = t!("label.png_saved", path = path.display().to_string()).to_string(), + Err(e) => self.status = t!("label.png_error", e = format!("{e}")).to_string(), + } + } + Task::none() + } + Message::ContextMenuExportCsvAt(idx) => { + let ts = self.dump.as_ref() + .and_then(|d| d.captures.get(idx)) + .map(|e| e.timestamp); + self.context_export_idx = Some(idx); + Task::perform(pick_save_csv(ts), Message::ContextMenuExportCsvPicked) + } + Message::ContextMenuExportCsvPicked(None) => { + self.context_export_idx = None; + Task::none() + } + Message::ContextMenuExportCsvPicked(Some(path)) => { + let idx = self.context_export_idx.take().unwrap_or(self.current); + if let Some(cap) = self.dump.as_ref().and_then(|d| d.captures.get(idx)).map(|e| &e.capture) { + match export_csv(&path, cap) { + Ok(()) => self.status = t!("label.csv_written", path = path.display().to_string()).to_string(), + Err(e) => self.status = t!("label.csv_error", e = format!("{e}")).to_string(), + } + } + Task::none() + } + Message::OpenFirmware => { + self.show_firmware = true; + self.firmware_port = self.settings.serial_port.clone().unwrap_or_default(); + self.firmware_file = None; + self.firmware_progress = None; + self.firmware_status = t!("label.firmware_status_ready").to_string(); + self.firmware_handle = None; + self.firmware_rx = None; + self.firmware_confirming = false; + self.was_listening_before_flash = self.listening; + Task::none() + } + Message::CloseFirmware => { + if let Some(mut h) = self.firmware_handle.take() { + h.cancel(); + } + self.firmware_rx = None; + self.show_firmware = false; + self.firmware_confirming = false; + // Auto-reconnect if we were listening before + if self.was_listening_before_flash && !self.listening { + if let Some(port) = &self.settings.serial_port { + if self.available_ports.contains(port) { + return self.update(Message::StartListening); + } + } + } + Task::none() + } + Message::FirmwarePortSelected(p) => { + self.firmware_port = p; + Task::none() + } + Message::FirmwareRefreshPorts => { + self.available_ports = serial::list_ports(); + Task::none() + } + Message::FirmwareFileClicked => { + Task::perform(pick_firmware_file(), Message::FirmwareFilePicked) + } + Message::FirmwareFilePicked(path) => { + self.firmware_file = path; + Task::none() + } + Message::FirmwareConfirmStart => { + self.firmware_confirming = true; + self.firmware_status = t!("label.firmware_confirm").to_string(); + Task::none() + } + Message::FirmwareStart => { + self.firmware_confirming = false; + let port = self.firmware_port.clone(); + let file = match &self.firmware_file { + Some(p) => p.to_string_lossy().to_string(), + None => { + self.firmware_status = t!("label.firmware_no_file").to_string(); + return Task::none(); + } + }; + if port.is_empty() { + self.firmware_status = t!("label.firmware_no_port").to_string(); + return Task::none(); + } + // Stop listening if active (can't share port) + if self.listening { + if let Some(mut h) = self.serial_handle.take() { + h.stop(); + } + self.serial_rx = None; + self.listening = false; + } + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let handle = crate::flash::start_flash(port, file, tx); + self.firmware_handle = Some(handle); + self.firmware_rx = Some(rx); + self.firmware_progress = Some((0, 1)); + self.firmware_status = t!("label.firmware_starting").to_string(); + Task::none() + } + Message::FirmwareEvent(_) => { + // Drain firmware channel + let mut finished = false; + if let Some(rx) = &mut self.firmware_rx { + while let Ok(evt) = rx.try_recv() { + match evt { + crate::flash::FlashEvent::Status(s) => { + if !s.is_empty() { + self.firmware_status = s; + } + } + crate::flash::FlashEvent::Progress { current, total } => { + self.firmware_progress = Some((current, total)); + } + crate::flash::FlashEvent::Finished(result) => { + match result { + Ok(()) => { + self.firmware_status = t!("label.firmware_complete").to_string(); + } + Err(e) => { + self.firmware_status = t!("label.firmware_error", e = format!("{e}")).to_string(); + } + } + finished = true; + } + } + } + } + if finished { + self.firmware_handle = None; + self.firmware_rx = None; + } + Task::none() + } + } + } + + // ── View ─────────────────────────────────────────────────────────── + + pub fn view(&self) -> Element<'_, Message> { + if self.show_firmware { + return self.firmware_dialog(); + } + if self.show_instructions { + return self.instructions_overlay(); + } + if self.show_settings { + return self.settings_dialog(); + } + + let menu_bar = self.menu_bar(); + + // Capture title with timestamp + let title_bar: Element<'_, Message> = if let Some(entry) = self.current_entry() { + let ts_str = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); + container(text(ts_str).size(12)) + .width(Length::Fill) + .center_x(Length::Fill) + .padding(2) + .into() + } else { + Space::new().height(Length::Fixed(0.0)).into() + }; + + let scope = self.scope_widget(); + let right = self.measurement_panel(); + let graph_row: Element<'_, Message> = container(row![scope, right].spacing(4)) + .width(Length::Fill) + .height(Length::FillPortion(1)) + .into(); + let controls = self.controls_panel(); + let thumbs = thumbnails(&self.dump, self.current, self.deleted_capture.is_some()); + let actions = self.action_buttons(); + + let bottom = column![ + controls, + rule::horizontal(1), + thumbs, + rule::horizontal(1), + actions, + ] + .spacing(4) + .padding(4); + + container(column![menu_bar, title_bar, graph_row, bottom].spacing(2)) + .padding(4) + .into() + } + + fn settings_dialog(&self) -> Element<'_, Message> { + let title = text(t!("label.settings_title")).size(22); + + // ── Language / Display card ── + let lang_display_names = crate::i18n::language_display_names(); + let lang_options: Vec = lang_display_names + .iter() + .map(|(code, display)| LangOption { + code: code.clone(), + display: display.clone(), + }) + .collect(); + let current_lang = lang_options + .iter() + .find(|o| o.code == self.settings.language) + .cloned(); + let lang_pick = pick_list( + lang_options, + current_lang, + |opt: LangOption| Message::ChangeLanguage(opt.code), + ); + + let display_card = container( + column![ + row![ + text(t!("label.display_section")).size(15), + Space::new().width(10.0), + text("🎨").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + row![ + text(t!("label.language")).size(13), + Space::new().width(Length::Fill), + lang_pick, + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + row![ + text(t!("label.png_width")).size(13), + Space::new().width(Length::Fill), + text_input("0", &self.png_width_input) + .on_input(Message::SettingsPngWidthChanged) + .size(13) + .width(Length::Fixed(70.0)), + ] + .align_y(iced::Alignment::Center), + ] + .spacing(8), + ) + .padding(16) + .style(container::bordered_box) + .width(Length::Fill); + + // ── Serial port card ── + let serial_card = container( + column![ + row![ + text(t!("label.serial_section")).size(15), + Space::new().width(10.0), + text("🔌").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + text(t!("label.default_port")).size(13), + text_input("e.g. /dev/tty.usbserial-1110", &self.settings_port_input) + .on_input(Message::SettingsPortChanged) + .size(13), + if self.available_ports.is_empty() { + text(t!("label.no_ports")).size(11) + } else { + text(t!("label.available_ports", ports = self.available_ports.join(", "))).size(11) + }, + text(t!("label.baud_rate")).size(13), + text_input("115200", &self.settings_baud_input) + .on_input(Message::SettingsBaudChanged) + .size(13), + checkbox(self.settings.auto_listen) + .label(t!("label.auto_listen")) + .on_toggle(Message::ToggleAutoListen), + checkbox(self.settings.alert_on_data) + .label(t!("label.beep_on_capture")) + .on_toggle(Message::ToggleAlertOnData), + ] + .spacing(8), + ) + .padding(16) + .style(container::bordered_box) + .width(Length::Fill); + + // ── Maintenance card (firmware updates) ── + let maintenance_card = container( + column![ + row![ + text(t!("label.firmware_title")).size(15), + Space::new().width(10.0), + text("🛠️").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + text(t!("label.firmware_warning")).size(10), + Space::new().height(8.0), + button(text(t!("btn.firmware_update")).size(13)) + .padding([6, 14]) + .on_press(Message::OpenFirmware), + ] + .spacing(4), + ) + .padding(16) + .style(container::bordered_box) + .width(Length::Fill); + + // ── About card ── + let ver = env!("CARGO_PKG_VERSION"); + let repo = env!("CARGO_PKG_REPOSITORY"); + let config_path = crate::settings::config_dir_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let about_card = container( + column![ + row![ + text(t!("label.about_section")).size(15), + Space::new().width(10.0), + text("ⓘ").size(20), + ] + .align_y(iced::Alignment::Center), + Space::new().height(10.0), + text(format!("DSO3D12 GUI v{ver}")).size(13), + button(text(repo).size(11)) + .on_press(Message::OpenUrl(repo.to_string())) + .style(button::text), + text(format!("{}{}", t!("label.config_path"), config_path)).size(11), + ] + .spacing(6), + ) + .padding(16) + .style(container::bordered_box) + .width(Length::Fill); + + let close_btn = button(text(t!("btn.save")).size(13)).on_press(Message::CloseSettings); + + scrollable( + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + row![display_card, serial_card].spacing(8).width(Length::Fill), + Space::new().height(Length::Fixed(8.0)), + row![maintenance_card, about_card].spacing(8).width(Length::Fill), + Space::new().height(Length::Fixed(16.0)), + close_btn, + ] + .spacing(4) + .padding(20) + .max_width(700), + ) + .center_x(Length::Fill) + .center_y(Length::Fill), + ) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new().scroller_width(0.0).width(0.0), + )) + .width(Length::Fill) + .into() + } + + fn firmware_dialog(&self) -> Element<'_, Message> { + let title = text(t!("label.firmware_title")).size(22); + let warning = container( + text(t!("label.firmware_warning")) + .size(12), + ) + .padding(8) + .style(container::bordered_box); + + let boot_instructions = container( + column![ + text(t!("label.boot_title")).size(13), + Space::new().height(Length::Fixed(4.0)), + text(t!("label.boot_1")).size(11), + text(t!("label.boot_2")).size(11), + ] + .spacing(2), + ) + .padding(8) + .style(container::bordered_box); + + let port_picker = pick_list( + self.available_ports.clone(), + if self.firmware_port.is_empty() { + None + } else { + Some(self.firmware_port.clone()) + }, + Message::FirmwarePortSelected, + ) + .placeholder(t!("label.port")) + .text_size(13); + let refresh_btn = button(text(t!("btn.refresh")).size(12)).on_press(Message::FirmwareRefreshPorts); + + let file_label = match &self.firmware_file { + Some(p) => text(p.file_name().and_then(|n| n.to_str()).unwrap_or("?")).size(13), + None => text(t!("label.no_file")).size(13), + }; + let file_btn = button(text(t!("btn.browse")).size(13)).on_press(Message::FirmwareFileClicked); + + let progress_bar_el: Element<'_, Message> = if let Some((cur, total)) = self.firmware_progress + { + let pct = if total > 0 { + (cur as f32 / total as f32) * 100.0 + } else { + 0.0 + }; + column![ + text(t!("label.pct_progress", pct = format!("{:.0}", pct), cur = cur, total = total)).size(12), + progress_bar(0.0..=total as f32, cur as f32), + ] + .spacing(2) + .into() + } else { + Space::new().height(Length::Fixed(0.0)).into() + }; + + let status_text = text(self.firmware_status.clone()).size(12); + // status_text is dynamically set, not translated here + + let is_flashing = self.firmware_handle.is_some(); + let update_btn = if is_flashing { + button(text(t!("btn.updating")).size(13)) + } else if self.firmware_confirming { + button(text(t!("btn.confirm_update")).size(13)).on_press(Message::FirmwareStart) + } else { + button(text(t!("btn.update")).size(13)).on_press(Message::FirmwareConfirmStart) + }; + let close_btn = button(text(t!("btn.close")).size(13)).on_press(Message::CloseFirmware); + + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + warning, + Space::new().height(Length::Fixed(8.0)), + boot_instructions, + Space::new().height(Length::Fixed(8.0)), + text(t!("label.port")).size(13), + row![port_picker, refresh_btn].spacing(8).align_y(iced::Alignment::Center), + Space::new().height(Length::Fixed(8.0)), + text(t!("label.firmware_file")).size(13), + row![file_btn, file_label].spacing(8).align_y(iced::Alignment::Center), + Space::new().height(Length::Fixed(8.0)), + progress_bar_el, + status_text, + Space::new().height(Length::Fixed(16.0)), + row![update_btn, close_btn].spacing(8), + ] + .spacing(4) + .padding(20) + .max_width(500), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + + fn instructions_overlay(&self) -> Element<'_, Message> { + let title = text(t!("label.instructions_title")).size(22); + + let content = instructions_panel(); + + let close_btn = button(text(t!("btn.close")).size(13)).on_press(Message::CloseInstructions); + + container( + column![ + title, + Space::new().height(Length::Fixed(8.0)), + content, + Space::new().height(Length::Fixed(16.0)), + close_btn, + ] + .spacing(4) + .padding(20) + .max_width(700), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + + fn menu_bar(&self) -> Element<'_, Message> { + let port_display = match &self.settings.serial_port { + Some(p) => text(p.clone()).size(12), + None => text(t!("app.no_port")).size(12), + }; + + let listen_btn = if self.listening { + button(text(t!("btn.stop")).size(12)).on_press(Message::StopListening) + } else { + button(text(t!("btn.listen")).size(12)).on_press(Message::StartListening) + }; + + let port_picker = pick_list( + self.available_ports.clone(), + self.settings.serial_port.clone(), + Message::PortSelected, + ) + .placeholder(t!("label.port")) + .text_size(12); + + let help_btn = button(text(t!("btn.help")).size(12)).on_press(Message::OpenInstructions); + + let status_row = row![ + text(t!("app.title")).size(14), + Space::new().width(Length::Fixed(12.0)), + port_picker, + port_display, + listen_btn, + Space::new().width(Length::Fill), + text(self.status.clone()).size(12), + Space::new().width(Length::Fixed(8.0)), + help_btn + ] + .spacing(6) + .align_y(iced::Alignment::Center); + + container(status_row) + .padding(4) + .width(Length::Fill) + .style(container::rounded_box) + .into() + } + + /// Apply oscilloscope state metadata (V/div, V/offset, t/div) to the + /// current settings and input fields. Used when loading captures that + /// contain embedded scope_state (screenshot packets or .zwcap files). + /// + /// Note: `v_offset_v` is stored with the same sign as the device's + /// `zero_volt_uv` field. The device's `pixel_to_volts` formula at + /// screen centre gives `−zero_volt_pixels × vpp × mult`, i.e. the + /// *opposite* sign, so we negate here. + fn apply_scope_state(&mut self, state: &dso_parser::ScopeState) { + if let Some(ref ch1) = state.ch1 { + self.settings.v_per_div_ch1 = ch1.vdiv_v; + self.settings.v_offset_ch1 = -ch1.v_offset_v; + } + if let Some(ref ch2) = state.ch2 { + self.settings.v_per_div_ch2 = ch2.vdiv_v; + self.settings.v_offset_ch2 = -ch2.v_offset_v; + } + self.settings.t_per_div_ms = state.timebase_ns_per_div as f64 / 1_000_000.0; + self.v_per_div_ch1_input = format_float(self.settings.v_per_div_ch1); + self.v_per_div_ch2_input = format_float(self.settings.v_per_div_ch2); + self.t_per_div_input = format_float(self.settings.t_per_div_ms); + self.v_offset_ch1_input = format_float(self.settings.v_offset_ch1); + self.v_offset_ch2_input = format_float(self.settings.v_offset_ch2); + self.settings.save(); + } + + /// Apply fine-drag scaling when Ctrl is held: only 1/10 of the raw + /// slider delta is applied to the logical cursor range. + fn apply_fine_drag( + ctrl_pressed: bool, + drag_anchor: &mut Option, + target_range: &mut (f32, f32), + raw_lo: f32, + raw_hi: f32, + ) { + if ctrl_pressed { + let drag = drag_anchor.get_or_insert(FineRangeDrag { + logical_start: *target_range, + raw_start: (raw_lo, raw_hi), + }); + + let lo = + drag.logical_start.0 + (raw_lo - drag.raw_start.0) * FINE_DRAG_FACTOR; + let hi = + drag.logical_start.1 + (raw_hi - drag.raw_start.1) * FINE_DRAG_FACTOR; + + *target_range = (lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0)); + } else { + *drag_anchor = None; + *target_range = (raw_lo, raw_hi); + } + } + + fn scope_widget(&self) -> Element<'_, Message> { + let cap = self.current_capture(); + log::trace!( + "scope_widget: capture present={}, ch1 len={}", + cap.is_some(), + cap.map(|c| c.ch1.len()).unwrap_or(0) + ); + let n_samples = cap.map(|c| c.ch1.len()).unwrap_or(DEFAULT_BUFFER_DEPTH); + let has_ch2_data = cap.and_then(|c| c.ch2.as_ref()).is_some(); + let scope = Scope { + capture: cap, + show_ch1: self.settings.show_ch1, + show_ch2: self.settings.show_ch2, + view_mode: self.settings.view_mode, + show_scales: self.settings.show_scales, + n_samples, + has_ch1: self.settings.show_ch1, + has_ch2: self.settings.show_ch2 && has_ch2_data, + meas_cursor_x: if self.settings.measurement_cursor_x_enabled { + Some(self.settings.cursor_x_range) + } else { + None + }, + meas_cursor_y: if self.settings.measurement_cursor_y_enabled { + Some(self.settings.cursor_y_range) + } else { + None + }, + device_cursor_x: if self.settings.device_cursor_x_enabled { + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .filter(|s| s.cursors_x_enable) + .map(|s| (s.cursor_x1_frac as f32, s.cursor_x2_frac as f32)) + } else { + None + }, + device_cursor_y: if self.settings.device_cursor_y_enabled { + self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .filter(|s| s.cursors_y_enable) + .map(|s| (s.cursor_y1_frac as f32, s.cursor_y2_frac as f32)) + } else { + None + }, + v_per_div_ch1: self.settings.v_per_div_ch1, + v_per_div_ch2: self.settings.v_per_div_ch2, + t_per_div_ms: self.settings.t_per_div_ms, + v_offset_ch1: self.settings.v_offset_ch1, + v_offset_ch2: self.settings.v_offset_ch2, + active_channel: self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .map(|s| s.active_channel) + .unwrap_or(0), + on_click: Some(Message::GraphClicked), + }; + canvas(scope) + .width(Length::Fill) + .height(Length::FillPortion(1)) + .into() + } + + /// Right-hand measurement panel — signal info + two range sliders with + /// computed voltage/time/frequency measurements. + fn measurement_panel(&self) -> Element<'_, Message> { + let cap = self.current_capture(); + + let (xlo, xhi) = self.settings.cursor_x_range; + let (ylo, yhi) = self.settings.cursor_y_range; + + // Scale parameters (per-channel) + let v_per_div_ch1 = self.settings.v_per_div_ch1; + let v_per_div_ch2 = self.settings.v_per_div_ch2; + let t_per_div_ms = self.settings.t_per_div_ms; + let v_offset_ch1 = self.settings.v_offset_ch1; + let v_offset_ch2 = self.settings.v_offset_ch2; + let total_time_ms = 12.0 * t_per_div_ms; + + let mut items: Vec> = vec![ + text(t!("label.measurement")).size(13).into(), + rule::horizontal(1).into(), + ]; + + // ── X cursor (time) ── + items.push( + checkbox(self.settings.measurement_cursor_x_enabled) + .label(t!("label.cursor_x")) + .on_toggle(Message::ToggleMeasCursorX) + .into(), + ); + + if self.settings.measurement_cursor_x_enabled { + items.push(range_slider::horizontal(xlo, xhi, Message::MeasCursorXChanged)); + items.push(text(t!("label.ctrl_fine_drag")).size(9).color(Color::from_rgb(0.6, 0.6, 0.6)).into()); + let t_lo = xlo as f64 * total_time_ms; + let t_hi = xhi as f64 * total_time_ms; + let dt_ms = t_hi - t_lo; + let freq = if dt_ms > 0.0 { + format_frequency_hz(1000.0 / dt_ms) + } else { + "—".to_string() + }; + items.push( + column![ + text(format!("Δt = {}", format_duration_ms(dt_ms))).size(11), + text(format!("≈ freq: {freq}")).size(11), + ] + .spacing(2) + .into(), + ); + } + + items.push(Space::new().height(Length::Fixed(6.0)).into()); + + // ── Y cursor (voltage) ── + items.push( + checkbox(self.settings.measurement_cursor_y_enabled) + .label(t!("label.cursor_y")) + .on_toggle(Message::ToggleMeasCursorY) + .into(), + ); + + if self.settings.measurement_cursor_y_enabled { + items.push(range_slider::horizontal(ylo, yhi, Message::MeasCursorYChanged)); + // items.push(text(t!("label.ctrl_fine_drag")).size(9).color(Color::from_rgb(0.6, 0.6, 0.6)).into()); + let v_ch1_lo = frac_to_voltage(ylo as f64, v_per_div_ch1, v_offset_ch1); + let v_ch1_hi = frac_to_voltage(yhi as f64, v_per_div_ch1, v_offset_ch1); + let v_ch2_lo = frac_to_voltage(ylo as f64, v_per_div_ch2, v_offset_ch2); + let v_ch2_hi = frac_to_voltage(yhi as f64, v_per_div_ch2, v_offset_ch2); + let dv_ch1 = (v_ch1_hi - v_ch1_lo).abs(); + let dv_ch2 = (v_ch2_hi - v_ch2_lo).abs(); + items.push( + column![ + text(t!("label.ch1_dv", v = format!("{:.3}", dv_ch1))).size(11), + text(format!(" ({v_ch1_lo:.2}V … {v_ch1_hi:.2}V)")).size(10), + text(t!("label.ch2_dv", v = format!("{:.3}", dv_ch2))).size(11), + text(format!(" ({v_ch2_lo:.2}V … {v_ch2_hi:.2}V)")).size(10), + ] + .spacing(0) + .into(), + ); + } + + items.push(Space::new().height(Length::Fixed(6.0)).into()); + items.push(rule::horizontal(1).into()); + + // ── Scale inputs (two-column layout: CH1 | CH2) ── + let col_w = Length::Fixed(85.0); + let input_w = Length::Fixed(42.0); + items.push( + row![ + text("").size(11).width(col_w), + text("CH1").size(11).width(input_w), + text("CH2").size(11).width(input_w), + ] + .spacing(2) + .into(), + ); + // V/div row + items.push( + row![ + text(t!("label.vdiv")).size(11).width(col_w), + text_input("1.0", &self.v_per_div_ch1_input) + .on_input(Message::VPerCellChangedCh1) + .size(11) + .width(input_w), + text_input("1.0", &self.v_per_div_ch2_input) + .on_input(Message::VPerCellChangedCh2) + .size(11) + .width(input_w), + ] + .spacing(2) + .align_y(iced::Alignment::Center) + .into(), + ); + // Probe row (from device metadata, if available) + let probe_ch1 = self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .and_then(|s| s.ch1.as_ref()) + .map(|c| c.probe.clone()) + .unwrap_or_default(); + let probe_ch2 = self.current_entry() + .and_then(|e| e.record.scope_state.as_ref()) + .and_then(|s| s.ch2.as_ref()) + .map(|c| c.probe.clone()) + .unwrap_or_default(); + if !probe_ch1.is_empty() || !probe_ch2.is_empty() { + let p1 = if probe_ch1.is_empty() { "—".to_string() } else { probe_ch1 }; + let p2 = if probe_ch2.is_empty() { "—".to_string() } else { probe_ch2 }; + items.push( + row![ + text(t!("label.probe")).size(11).width(col_w), + text(p1).size(11).width(input_w), + text(p2).size(11).width(input_w), + ] + .spacing(2) + .into(), + ); + } + // V/off row + items.push( + row![ + text(t!("label.voff")).size(11).width(col_w), + text_input("0.0", &self.v_offset_ch1_input) + .on_input(Message::VOffsetChangedCh1) + .size(11) + .width(input_w), + text_input("0.0", &self.v_offset_ch2_input) + .on_input(Message::VOffsetChangedCh2) + .size(11) + .width(input_w), + ] + .spacing(2) + .align_y(iced::Alignment::Center) + .into(), + ); + // t/div row (shared, spans both channels) + items.push( + row![ + text(t!("label.tdiv")).size(11).width(col_w), + text_input("1.0", &self.t_per_div_input) + .on_input(Message::TPerCellChanged) + .size(11) + .width(Length::Fixed(90.0)) + ] + .spacing(2) + .align_y(iced::Alignment::Center) + .into(), + ); + items.push(text(t!("label.ac_mode")).size(9).into()); + items.push(rule::horizontal(1).into()); + + // ── Signal information (per-channel) ── + if let Some(cap) = cap { + let (ch1_mn, ch1_mx, _) = stats(&cap.ch1); + let v_ch1_neg = v_offset_ch1 + (ADC_VALUE_MID as f64 - ch1_mx as f64) * v_per_div_ch1 / PX_PER_DIV; + let v_ch1_pos = v_offset_ch1 + (ADC_VALUE_MID as f64 - ch1_mn as f64) * v_per_div_ch1 / PX_PER_DIV; + items.push( + text(format!("CH1 range: {v_ch1_neg:.2}V … {v_ch1_pos:.2}V")).size(11).into(), + ); + if let Some(ch2) = &cap.ch2 { + let (ch2_mn, ch2_mx, _) = stats(ch2); + let v_ch2_neg = v_offset_ch2 + (ADC_VALUE_MID as f64 - ch2_mx as f64) * v_per_div_ch2 / PX_PER_DIV; + let v_ch2_pos = v_offset_ch2 + (ADC_VALUE_MID as f64 - ch2_mn as f64) * v_per_div_ch2 / PX_PER_DIV; + items.push( + text(format!("CH2 range: {v_ch2_neg:.2}V … {v_ch2_pos:.2}V")) + .size(11) + .into(), + ); + } + items.push( + text(format!("Time range: {total_time_ms:.2} ms ({} samp)", cap.ch1.len())) + .size(11) + .into(), + ); + items.push(rule::horizontal(1).into()); + } + + // ── Per-channel stats ── + if let Some(cap) = cap { + let (mn, mx, av) = stats(&cap.ch1); + items.push( + text(format!( + "CH1: n={} min={mn} max={mx} avg={av:.1}", + cap.ch1.len() + )) + .size(10) + .into(), + ); + if let Some(ch2) = &cap.ch2 { + let (mn, mx, av) = stats(ch2); + items.push( + text(format!("CH2: n={} min={mn} max={mx} avg={av:.1}", ch2.len())) + .size(10) + .into(), + ); + } + } + + // ── Trigger + measurements from screenshot metadata ── + if let Some(scope_state) = self.current_entry().and_then(|e| e.record.scope_state.as_ref()) { + items.push(rule::horizontal(1).into()); + items.push(text(t!("label.trigger")).size(11).into()); + items.push( + text(format!( + "Mode: {} | Edge: {} | Src: {}", + scope_state.trigger_mode, scope_state.trigger_edge, scope_state.trigger_source + )) + .size(10) + .into(), + ); + items.push( + text(format!("Level: {:.3} V", scope_state.trigger_level_v)) + .size(10) + .into(), + ); + // Channel probe/coupling info + if let Some(ref ch1) = scope_state.ch1 { + items.push( + text(format!("CH1: {} probe, {}", ch1.probe, ch1.coupling)) + .size(10) + .into(), + ); + } + if let Some(ref ch2) = scope_state.ch2 { + items.push( + text(format!("CH2: {} probe, {}", ch2.probe, ch2.coupling)) + .size(10) + .into(), + ); + } + items.push( + text(format!( + "Delay: {:.6} s | Rate: {:.0} Sa/s", + scope_state.trigger_delay_s, scope_state.sample_rate_hz + )) + .size(10) + .into(), + ); + + // ── Auto-measurements table ── + let has_m1 = scope_state.ch1.as_ref().and_then(|c| c.measurements.as_ref()); + let has_m2 = scope_state.ch2.as_ref().and_then(|c| c.measurements.as_ref()); + if has_m1.is_some() || has_m2.is_some() { + items.push(rule::horizontal(1).into()); + items.push(text(t!("label.auto_meas")).size(11).into()); + + let fmt_v = |v: f64| -> String { + if v.abs() >= 1.0 { format!("{v:.3} V") } + else { format!("{:.2} mV", v * 1000.0) } + }; + let fmt_t = |s: f64| -> String { + if s >= 1.0 { format!("{s:.3} s") } + else if s >= 0.001 { format!("{:.3} ms", s * 1000.0) } + else if s >= 0.000_001 { format!("{:.2} µs", s * 1_000_000.0) } + else { format!("{:.2} ns", s * 1_000_000_000.0) } + }; + let fmt_hz = |hz: f64| -> String { + if hz >= 1_000_000.0 { format!("{:.3} MHz", hz / 1_000_000.0) } + else if hz >= 1000.0 { format!("{:.3} kHz", hz / 1000.0) } + else { format!("{hz:.2} Hz") } + }; + + // Build measurement rows: label | CH1 | CH2 + let ch_val = |m: Option<&dso_parser::ChannelMeasurements>, idx: usize| -> String { + match m { + None => "—".to_string(), + Some(m) => { + let fv = |v: f64| if v.is_nan() { "—".to_string() } else { fmt_v(v) }; + let ft = |v: f64| if v.is_nan() { "—".to_string() } else { fmt_t(v) }; + let fd = |v: f64| if v.is_nan() { "—".to_string() } else { format!("{:.1}%", v) }; + let fh = |v: f64| if v.is_nan() { "—".to_string() } else { fmt_hz(v) }; + match idx { + 0 => fh(m.freq_hz), + 1 => fv(m.pk_pk_v), + 2 => fv(m.avg_v), + 3 => fv(m.rms_v), + 4 => fv(m.amplitude_v), + 5 => fd(m.pos_duty_pct), + 6 => fd(m.neg_duty_pct), + 7 => ft(m.pos_width_s), + 8 => ft(m.neg_width_s), + 9 => ft(m.period_s), + 10 => fv(m.max_v), + 11 => fv(m.min_v), + 12 => fv(m.top_v), + 13 => fv(m.base_v), + _ => "—".to_string(), + } + } + } + }; + + // Header row + items.push( + row![ + text("").size(9).width(Length::Fixed(36.0)), + text(t!("label.ch1")).size(9).width(Length::Fixed(72.0)), + text(t!("label.ch2")).size(9).width(Length::Fixed(72.0)), + ] + .spacing(2) + .into(), + ); + + let label_data: &[(u8, &str)] = &[ + (0, "Freq"), (1, "PkPk"), (2, "Avg"), (3, "RMS"), (4, "Amp"), + (5, "+Duty"), (6, "-Duty"), (7, "+T"), (8, "-T"), (9, "T"), + (10, "Max"), (11, "Min"), (12, "Top"), (13, "Base"), + ]; + for &(idx, _key) in label_data { + let v1 = ch_val(has_m1, idx as usize); + let v2 = ch_val(has_m2, idx as usize); + let label = match idx { + 0 => t!("label.freq_name").to_string(), + 1 => t!("label.pkpk_name").to_string(), + 2 => t!("label.avg_name").to_string(), + 3 => t!("label.rms_name").to_string(), + 4 => t!("label.amp_name").to_string(), + 5 => t!("label.duty_plus_name").to_string(), + 6 => t!("label.duty_minus_name").to_string(), + 7 => t!("label.t_plus_name").to_string(), + 8 => t!("label.t_minus_name").to_string(), + 9 => t!("label.t_name").to_string(), + 10 => t!("label.max_name").to_string(), + 11 => t!("label.min_name").to_string(), + 12 => t!("label.top_name").to_string(), + 13 => t!("label.base_name").to_string(), + _ => unreachable!(), + }; + items.push( + row![ + text(label).size(9).width(Length::Fixed(36.0)), + text(v1).size(9).width(Length::Fixed(72.0)), + text(v2).size(9).width(Length::Fixed(72.0)), + ] + .spacing(2) + .into(), + ); + } + } + } + + scrollable( + container(column(items).spacing(4).padding(6)) + .width(Length::Fixed(240.0)) + .style(container::rounded_box), + ) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new(), + )) + .width(Length::Fixed(240.0)) + .into() + } + + fn controls_panel(&self) -> Element<'_, Message> { + let has_dev_x = self.capture_has_device_cursor_x(); + let has_dev_y = self.capture_has_device_cursor_y(); + + let dev_x = { + let cb = checkbox(self.settings.device_cursor_x_enabled).label(t!("label.device_x")); + if has_dev_x { + cb.on_toggle(Message::ToggleDeviceCursorX) + } else { + cb + } + }; + let dev_y = { + let cb = checkbox(self.settings.device_cursor_y_enabled).label(t!("label.device_y")); + if has_dev_y { + cb.on_toggle(Message::ToggleDeviceCursorY) + } else { + cb + } + }; + + row![ + checkbox(self.settings.show_ch1) + .label(t!("label.ch1")) + .on_toggle(Message::ToggleCh1), + checkbox(self.settings.show_ch2) + .label(t!("label.ch2")) + .on_toggle(Message::ToggleCh2), + Space::new().width(Length::Fixed(16.0)), + dev_x, + dev_y, + Space::new().width(Length::Fixed(16.0)), + checkbox(self.settings.show_scales) + .label(t!("label.scales")) + .on_toggle(Message::ToggleShowScales), + Space::new().width(Length::Fixed(16.0)), + text(t!("label.view")).size(13), + pick_list( + ViewMode::ALL.as_slice(), + Some(self.settings.view_mode), + Message::ViewModeSelected, + ) + .text_size(13), + ] + .spacing(8) + .padding(4) + .align_y(iced::Alignment::Center) + .into() + } + + fn action_buttons(&self) -> Element<'_, Message> { + row![ + button(text(t!("btn.load_capture")).size(12)).on_press(Message::LoadClicked), + button(text(t!("btn.save_capture")).size(12)).on_press(Message::SaveCaptureClicked), + Space::new().width(Length::Fixed(12.0)), + text("│").size(12), + Space::new().width(Length::Fixed(12.0)), + button(text(t!("btn.save_png")).size(12)).on_press(Message::SavePngClicked), + checkbox(self.settings.split_png) + .label(t!("label.split_ch")) + .on_toggle(Message::ToggleSplitPng), + Space::new().width(Length::Fixed(12.0)), + text("│").size(12), + Space::new().width(Length::Fixed(12.0)), + button(text(t!("btn.export_data")).size(12)).on_press(Message::ExportCsvClicked), + Space::new().width(Length::Fill), + button(text(t!("btn.settings")).size(12)).on_press(Message::OpenSettings), + ] + .spacing(6) + .padding(4) + .align_y(iced::Alignment::Center) + .into() + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── +fn instructions_panel<'a>() -> Element<'a, Message> { + // Keep each complete instruction block in a separate string so it can be + // moved into locale files later without restructuring the widget tree. + let standard_firmware_instructions = format!( + "{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + t!("label.standard_fw_title"), + t!("label.standard_fw_1"), + t!("label.standard_fw_2"), + t!("label.standard_fw_3"), + t!("label.standard_fw_4"), + t!("label.standard_fw_5"), + t!("label.standard_fw_6"), + t!("label.standard_fw_7"), + ); + + let customized_firmware_instructions = format!( + "{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + t!("label.customized_fw_title"), + t!("label.customized_fw_1"), + t!("label.customized_fw_2"), + t!("label.customized_fw_3"), + t!("label.customized_fw_4"), + t!("label.customized_fw_5"), + t!("label.customized_fw_6"), + t!("label.customized_fw_7"), + ); + + let application_instructions = format!( + "{}\n\n{}\n\n{}\n\n{}", + t!("label.using_viewer_title"), + t!("label.using_viewer_1"), + t!("label.using_viewer_2"), + t!("label.using_viewer_3"), + ); + + let standard_firmware = container( + text(standard_firmware_instructions) + .size(11) + .width(Length::Fill), + ) + .padding(6) + .width(Length::FillPortion(1)); + + let customized_firmware = container( + column![ + button(text(t!("btn.help")).size(11)) + .on_press(Message::OpenUrl( + "https://github.com/taligentx/ZeeTweak".to_owned(), + )) + .style(button::text), + text(customized_firmware_instructions) + .size(11) + .width(Length::Fill), + ] + .spacing(6), + ) + .padding(6) + .width(Length::FillPortion(1)); + + let application = container( + text(application_instructions) + .size(11) + .width(Length::Fill), + ) + .padding(6) + .width(Length::Fill); + + container( + column![ + row![ + standard_firmware, + rule::vertical(1), + customized_firmware, + ] + .spacing(4) + .width(Length::Fill), + rule::horizontal(1), + application, + ] + .spacing(4) + .width(Length::Fill), + ) + .padding(4) + .width(Length::Fill) + .style(container::rounded_box) + .into() +} + +pub(crate) fn stats(samples: &[u8]) -> (u8, u8, f32) { + if samples.is_empty() { + return (0, 0, 0.0); + } + let mut mn = u8::MAX; + let mut mx = 0u8; + let mut sum: u64 = 0; + for &b in samples { + mn = mn.min(b); + mx = mx.max(b); + sum += b as u64; + } + (mn, mx, sum as f32 / samples.len() as f32) +} + +/// Convert raw ADC count (0-255) to approximate voltage using the default ±5V range. +/// Used in tests. +#[cfg(test)] +fn adc_to_voltage(adc: u8) -> f32 { + (adc as f32 - ADC_VALUE_MID as f32) * 5.0 / ADC_VALUE_MID as f32 +} + +/// Convert a fractional position (0.0..1.0) on the Y axis to voltage +/// using the user-configured V/div scale. +/// frac=0 → top → +half_range; frac=1 → bottom → -half_range. +/// Format a float for display in a scale input field. +/// Always shows a decimal point so the field looks like a float. +/// Strips trailing zeros but keeps at least one decimal digit (e.g. "1.0", "0.5", "0.001"). +fn format_float(v: f64) -> String { + // Use enough precision to round-trip small values + let s = format!("{:.9}", v); + let trimmed = s.trim_end_matches('0'); + if trimmed.ends_with('.') { + format!("{}0", trimmed) + } else { + trimmed.to_string() + } +} + +/// Format a time duration given in milliseconds into a human-readable string +/// with automatic unit switching (ms / µs / ns). +pub fn format_duration_ms(ms: f64) -> String { + if ms >= 1.0 { + format!("{ms:.3} ms") + } else if ms >= 0.001 { + format!("{:.3} µs", ms * 1000.0) + } else { + format!("{:.3} ns", ms * 1_000_000.0) + } +} + +/// Format a frequency in Hz into a human-readable string with automatic unit +/// switching (Hz / kHz / MHz). +fn format_frequency_hz(hz: f64) -> String { + if hz >= 1_000_000.0 { + format!("{:.3} MHz", hz / 1_000_000.0) + } else if hz >= 1000.0 { + format!("{:.3} kHz", hz / 1000.0) + } else { + format!("{hz:.2} Hz") + } +} + +/// Grid constants from canvas.rs: 8 divs × 25 px/div = 200 px grid height. +use crate::canvas::{GRID_HEIGHT_PX, PX_PER_DIV}; + +/// Convert a fractional Y position on the graph (0.0 = top, 1.0 = bottom) +/// to voltage. Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PX_PER_DIV, +/// where v = frac × GRID_HEIGHT_PX + PX_PER_DIV matches draw_trace's offset +/// (v=25 maps to y=0, the top of the visible grid). +fn frac_to_voltage(frac: f64, v_per_div: f64, v_offset: f64) -> f64 { + let v = frac * GRID_HEIGHT_PX + PX_PER_DIV; + v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV +} + +/// Convert sample count to time in milliseconds at the default 1 MHz sample rate. +/// Used in tests. +#[cfg(test)] +const SAMPLE_RATE_HZ: f64 = 1_000_000.0; + +#[cfg(test)] +fn samples_to_time_ms(n: usize) -> f64 { + (n as f64 / SAMPLE_RATE_HZ) * 1000.0 +} + +/// Thumbnails strip: scrollable row of mini waveform canvases. +/// Each thumbnail shows the signal shape and is clickable. +fn thumbnails<'a>(dump: &'a Option, current: usize, can_undo: bool) -> Element<'a, Message> { + let is_empty = dump.as_ref().is_none_or(|d| d.captures.is_empty()); + if is_empty { + if can_undo { + return container( + button(text(t!("btn.undo_delete")).size(12)) + .on_press(Message::UndoDelete) + .style(button::secondary), + ) + .height(Length::Fixed(62.0)) + .width(Length::Fill) + .align_x(iced::Alignment::Start) + .align_y(iced::Alignment::Center) + .padding(4) + .into(); + } + return Space::new().height(Length::Fixed(0.0)).into(); + } + let dump = dump.as_ref().unwrap(); + let row_items = dump.captures.iter().enumerate().map(|(i, entry)| { + let thumb = Thumbnail { + capture: &entry.capture, + selected: i == current, + }; + let thumb_canvas: Element<'a, Message> = canvas(thumb) + .width(Length::Fixed(100.0)) + .height(Length::Fixed(50.0)) + .into(); + + let btn: Element<'a, Message> = button(thumb_canvas) + .on_press(Message::SelectCapture(i)) + .padding(2) + .style(if i == current { + button::primary + } else { + button::secondary + }) + .into(); + + // Wrap in ContextMenu for right-click popup + let tooltip_ts = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(); + let underlay: Element<'a, Message> = iced::widget::tooltip( + btn, + text(tooltip_ts.clone()).size(11), + iced::widget::tooltip::Position::Top, + ) + .into(); + + let ctx_idx = i; + let menu_ts = tooltip_ts.clone(); + iced_aw::ContextMenu::new(underlay, move || { + container( + iced::widget::column![ + text(menu_ts.clone()).size(10), + rule::horizontal(1), + button(text(t!("btn.export_png")).size(12)) + .on_press(Message::ContextMenuExportPngAt(ctx_idx)) + .width(Length::Fill) + .style(button::text), + button(text(t!("btn.export_csv")).size(12)) + .on_press(Message::ContextMenuExportCsvAt(ctx_idx)) + .width(Length::Fill) + .style(button::text), + rule::horizontal(1), + button(text(t!("btn.delete")).size(12)) + .on_press(Message::ContextMenuDeleteAt(ctx_idx)) + .width(Length::Fill) + .style(button::danger), + rule::horizontal(1), + { + let undo_btn = button(text(t!("btn.undo_delete")).size(12)) + .width(Length::Fill) + .style(button::text); + if can_undo { + undo_btn.on_press(Message::UndoDelete) + } else { + undo_btn + } + }, + ] + .spacing(1) + .padding(4) + .width(Length::Fixed(160.0)), + ) + .style(container::bordered_box) + .into() + }) + .into() + }); + container( + scrollable(row(row_items).spacing(4)) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default(), + )), + ) + .height(Length::Fixed(62.0)) + .into() +} + +// ── Async file pickers ───────────────────────────────────────────────────── + +async fn pick_file() -> Option { + rfd::AsyncFileDialog::new() + .add_filter(t!("label.zwcap_filter").to_string(), &["zwcap", "bin", "dat", "raw"]) + .add_filter(t!("label.all_files").to_string(), &["*"]) + .pick_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_save_bin() -> Option { + let now = chrono::Local::now(); + let name = format!("capture_{}.zwcap", now.format("%Y%m%d_%H%M%S")); + rfd::AsyncFileDialog::new() + .add_filter(t!("label.zwcap_filter").to_string(), &["zwcap"]) + .add_filter(t!("label.all_files").to_string(), &["*"]) + .set_file_name(&name) + .save_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_save_csv(ts: Option>) -> Option { + let t = ts.unwrap_or_else(Local::now); + let name = format!("capture_{}.csv", t.format("%Y%m%d_%H%M%S")); + rfd::AsyncFileDialog::new() + .add_filter(t!("label.csv_filter").to_string(), &["csv"]) + .set_file_name(&name) + .save_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_save_png(ts: Option>) -> Option { + let t = ts.unwrap_or_else(Local::now); + let name = format!("capture_{}.png", t.format("%Y%m%d_%H%M%S")); + rfd::AsyncFileDialog::new() + .add_filter(t!("label.png_filter").to_string(), &["png"]) + .set_file_name(&name) + .save_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn pick_firmware_file() -> Option { + rfd::AsyncFileDialog::new() + .add_filter(t!("label.fls_filter").to_string(), &["fls", "bin"]) + .add_filter(t!("label.all_files").to_string(), &["*"]) + .pick_file() + .await + .map(|h| h.path().to_path_buf()) +} + +async fn load_dump(path: PathBuf) -> Result { + log::info!("loading {}", path.display()); + let bytes = std::fs::read(&path)?; + + // Detect format: .zwcap container, binary screenshot, or ASCII debug dump + let entries = if dso_parser::is_zwcap(&bytes) { + // New container format + let records = dso_parser::deserialize_zwcap(&bytes) + .map_err(|e| anyhow::anyhow!("{e}"))?; + records.into_iter().map(|rec| { + let capture = dso_parser::record_to_capture(&rec); + let timestamp = rec.captured_at.as_ref() + .and_then(|s| chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%:z").ok()) + .map(|dt| dt.with_timezone(&Local)) + .unwrap_or_else(Local::now); + CaptureEntry { capture, timestamp, record: rec } + }).collect() + } else if dso_parser::is_screenshot_packet(&bytes) { + // Single binary screenshot packet + let pkt = dso_parser::parse_screenshot(&bytes)?; + let now = Local::now(); + let record = dso_parser::screenshot_to_record(&pkt, now, &bytes); + let capture = pkt.to_capture(); + vec![CaptureEntry { capture, timestamp: now, record }] + } else { + // Try ASCII debug dump (legacy format) + let parser_entries = dso_parser::load_captures_with_metadata(&bytes)?; + parser_entries.into_iter().map(|e| { + let record = dso_parser::capture_to_record(&e.capture, e.timestamp, None); + CaptureEntry { capture: e.capture, timestamp: e.timestamp, record } + }).collect() + }; + + Ok(LoadedDump { path, captures: entries }) +} + +// ── Beep helpers ─────────────────────────────────────────────────────────── + +/// Play a short "start receiving" beep sound. +fn play_beep_start() { + #[cfg(target_os = "macos")] + std::thread::spawn(|| { + let _ = std::process::Command::new("afplay") + .arg("/System/Library/Sounds/Tink.aiff") + .output(); + }); + + #[cfg(target_os = "linux")] + std::thread::spawn(|| { + // Try paplay (PulseAudio/PipeWire) with freedesktop sound theme. + let ok = std::process::Command::new("paplay") + .arg("/usr/share/sounds/freedesktop/stereo/bell.oga") + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + print!("\x07"); + } + }); + + #[cfg(target_os = "windows")] + std::thread::spawn(|| { + // Asterisk is the standard Windows "notification" system sound. + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "[System.Media.SystemSounds]::Asterisk.Play()"]) + .output(); + }); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + print!("\x07"); +} + +/// Play a "capture complete" beep sound (different from start). +fn play_beep_end() { + #[cfg(target_os = "macos")] + std::thread::spawn(|| { + let _ = std::process::Command::new("afplay") + .arg("/System/Library/Sounds/Glass.aiff") + .output(); + }); + + #[cfg(target_os = "linux")] + std::thread::spawn(|| { + // "complete" is the standard task-done sound in the freedesktop theme. + let ok = std::process::Command::new("paplay") + .arg("/usr/share/sounds/freedesktop/stereo/complete.oga") + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + print!("\x07"); + } + }); + + #[cfg(target_os = "windows")] + std::thread::spawn(|| { + // Exclamation is the standard Windows "action complete" system sound. + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "[System.Media.SystemSounds]::Exclamation.Play()"]) + .output(); + }); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + print!("\x07"); +} + +// ── File save/load helpers ───────────────────────────────────────────────── + +/// Save all captures to a `.zwcap` container file. +fn save_all_captures(path: &std::path::Path, entries: &[CaptureEntry]) -> anyhow::Result<()> { + let records: Vec = entries + .iter() + .map(|e| e.record.clone()) + .collect(); + let data = dso_parser::serialize_zwcap(&records); + std::fs::write(path, data)?; + Ok(()) +} + +fn export_csv(path: &std::path::Path, cap: &Capture) -> anyhow::Result<()> { + let mut wtr = csv::Writer::from_path(path)?; + if cap.ch2.is_some() { + wtr.write_record(["index", "ch1", "ch2"])?; + } else { + wtr.write_record(["index", "ch1"])?; + } + let n = cap.sample_count(); + for i in 0..n { + let v1 = cap.ch1.get(i).map(|b| b.to_string()).unwrap_or_default(); + if let Some(ch2) = &cap.ch2 { + let v2 = ch2.get(i).map(|b| b.to_string()).unwrap_or_default(); + wtr.write_record([i.to_string(), v1, v2])?; + } else { + wtr.write_record([i.to_string(), v1])?; + } + } + wtr.flush()?; + Ok(()) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use dso_parser::Capture; + + /// Helper to construct a test CaptureEntry from a Capture. + fn test_entry(capture: Capture) -> CaptureEntry { + let now = Local::now(); + let record = dso_parser::capture_to_record(&capture, now, None); + CaptureEntry { capture, timestamp: now, record } + } + + #[test] + fn stats_handles_empty() { + assert_eq!(stats(&[]), (0, 0, 0.0)); + } + + #[test] + fn stats_known_values() { + let (mn, mx, av) = stats(&[10, 20, 30, 40]); + assert_eq!(mn, 10); + assert_eq!(mx, 40); + assert!((av - 25.0).abs() < 1e-3); + } + + #[test] + fn app_default_no_capture() { + let (app, _) = App::new(); + assert!(app.current_capture().is_none()); + assert_eq!(app.current, 0); + assert!(!app.listening); + } + + #[test] + fn app_updates_measurement_range() { + let (mut app, _) = App::new(); + let _ = app.update(Message::MeasCursorXChanged(0.1, 0.9)); + assert_eq!(app.settings.cursor_x_range, (0.1, 0.9)); + } + + #[test] + fn app_select_capture_clamps() { + let (mut app, _) = App::new(); + app.dump = Some(LoadedDump { + path: PathBuf::from("test.bin"), + captures: vec![test_entry(Capture { + ch1: vec![1, 2, 3], + ch2: None, + })], + }); + let _ = app.update(Message::SelectCapture(99)); + assert_eq!(app.current, 0); + let _ = app.update(Message::SelectCapture(0)); + assert_eq!(app.current, 0); + } + + #[test] + fn toggling_channels_persists_in_settings() { + let (mut app, _) = App::new(); + let before = app.settings.show_ch1; + let _ = app.update(Message::ToggleCh1(!before)); + assert_eq!(app.settings.show_ch1, !before); + } + + #[test] + fn device_cursor_hooks_default_false() { + let (app, _) = App::new(); + assert!(!app.capture_has_device_cursor_x()); + assert!(!app.capture_has_device_cursor_y()); + } + + #[test] + fn view_renders_without_panic_when_empty() { + let (app, _) = App::new(); + let _ = app.view(); + } + + #[test] + fn view_renders_with_capture() { + let (mut app, _) = App::new(); + app.dump = Some(LoadedDump { + path: PathBuf::from("test.bin"), + captures: vec![test_entry(Capture { + ch1: (0..100).map(|i| i as u8).collect(), + ch2: None, + })], + }); + let _ = app.view(); + } + + #[test] + fn adc_voltage_conversion() { + // ADC_VALUE_MID → 0V (center), 0 → -5V, ADC_VALUE_MAX → ~+5V + assert!((adc_to_voltage(ADC_VALUE_MID) - 0.0).abs() < 0.05); + assert!((adc_to_voltage(0) - (-5.0)).abs() < 0.05); + assert!((adc_to_voltage(ADC_VALUE_MAX) - 4.96).abs() < 0.1); + } + + #[test] + fn samples_to_time_conversion() { + let ms = samples_to_time_ms(1000); + assert!((ms - 1.0).abs() < 0.01); + } + + #[test] + fn settings_dialog_opens_and_closes() { + let (mut app, _) = App::new(); + let _ = app.update(Message::OpenSettings); + assert!(app.show_settings); + let _ = app.update(Message::CloseSettings); + assert!(!app.show_settings); + } + + #[test] + fn serial_start_without_port_shows_error() { + let (mut app, _) = App::new(); + app.settings.serial_port = None; + let _ = app.update(Message::StartListening); + assert!(!app.listening); + assert!(app.status.contains("Select a serial port")); + } +} diff --git a/gui/src/canvas.rs b/gui/src/canvas.rs new file mode 100644 index 0000000..3b8369f --- /dev/null +++ b/gui/src/canvas.rs @@ -0,0 +1,592 @@ +//! Oscilloscope-style graph canvas (iced::widget::canvas). +use crate::settings::ViewMode; +use dso_parser::{ADC_VALUE_MID, Capture}; +use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke, Text}; +use iced::{mouse, Color, Point, Rectangle, Renderer, Size, Theme}; + +/// Drawable scope view backed by an optional `Capture`. +pub struct Scope<'a, Message = ()> { + pub capture: Option<&'a Capture>, + pub show_ch1: bool, + pub show_ch2: bool, + pub view_mode: ViewMode, + /// User-positioned measurement cursors: X range (low, high) in 0.0..1.0. + pub meas_cursor_x: Option<(f32, f32)>, + /// User-positioned measurement cursors: Y range (low, high) in 0.0..1.0. + pub meas_cursor_y: Option<(f32, f32)>, + /// Device-supplied X cursors (read-only, from capture data). + pub device_cursor_x: Option<(f32, f32)>, + /// Device-supplied Y cursors (read-only, from capture data). + pub device_cursor_y: Option<(f32, f32)>, + /// Show numeric axis scales on the graph. + pub show_scales: bool, + /// Number of samples for X scale calculation. + pub n_samples: usize, + /// Whether CH1 data is present in the capture (for scale labelling). + pub has_ch1: bool, + /// Whether CH2 data is present in the capture (for scale labelling). + pub has_ch2: bool, + /// Volts per division for CH1. 8 divisions total. + pub v_per_div_ch1: f64, + /// Volts per division for CH2. 8 divisions total. + pub v_per_div_ch2: f64, + /// Time per division in milliseconds. 12 divisions total. + pub t_per_div_ms: f64, + /// Voltage offset for CH1 (voltage at graph center). + pub v_offset_ch1: f64, + /// Voltage offset for CH2 (voltage at graph center). + pub v_offset_ch2: f64, + /// Active channel (0 = CH1, 1 = CH2). Used for device cursor voltage. + pub active_channel: u8, + /// Callback message factory for graph clicks: (frac_x, frac_y). + pub on_click: Option Message>, +} + +/// State for cursor drag interaction on the scope canvas. +#[derive(Default)] +pub struct ScopeState { + dragging: Option, +} + +#[derive(Debug, Clone, Copy)] +enum DragTarget { + XLow, + XHigh, + YLow, + YHigh, +} + +const BG: Color = Color::from_rgb(0.04, 0.06, 0.10); +const GRID: Color = Color::from_rgb(0.20, 0.24, 0.30); +const GRID_AXIS: Color = Color::from_rgb(0.35, 0.40, 0.46); +const CH1_COLOR: Color = Color::from_rgb(1.0, 0.85, 0.10); +const CH2_COLOR: Color = Color::from_rgb(0.95, 0.10, 0.95); +/// User-driven measurement cursors (interactive). +const MEAS_CURSOR: Color = Color::from_rgb(1.0, 0.30, 0.50); +const MEAS_FILL: Color = Color::from_rgba(1.0, 0.30, 0.50, 0.08); +/// Device-supplied cursors (read-only). +const DEV_CURSOR: Color = Color::from_rgb(0.40, 1.0, 0.40); + +/// Build a `Text` label for a device cursor with consistent styling. +fn dev_label(content: String, position: Point) -> Text { + Text { + content, + position, + color: DEV_CURSOR, + size: 10.0.into(), + ..Text::default() + } +} + +impl canvas::Program for Scope<'_, Message> { + type State = ScopeState; + + fn update( + &self, + state: &mut Self::State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + let on_click = self.on_click?; + let pos = cursor.position_in(bounds)?; + let gx = if self.show_scales { + match (self.has_ch1, self.has_ch2) { + (true, true) => SCALE_MARGIN_LEFT * 2.0, + (true, false) | (false, true) => SCALE_MARGIN_LEFT, + (false, false) => 0.0, + } + } else { + 0.0 + }; + let gw = bounds.width - gx; + let gy = if self.show_scales { SCALE_MARGIN_BOTTOM } else { 0.0 }; + let gh = bounds.height - gy; + let frac_x = ((pos.x - gx) / gw).clamp(0.0, 1.0); + let frac_y = (pos.y / gh).clamp(0.0, 1.0); + + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + // Determine which cursor line is nearest and start drag + let mut best_dist = f32::MAX; + let mut target: Option = None; + + if let Some((lo, hi)) = self.meas_cursor_x { + let d_lo = (frac_x - lo).abs(); + let d_hi = (frac_x - hi).abs(); + if d_lo < best_dist { + best_dist = d_lo; + target = Some(DragTarget::XLow); + } + if d_hi < best_dist { + best_dist = d_hi; + target = Some(DragTarget::XHigh); + } + } + if let Some((lo, hi)) = self.meas_cursor_y { + let d_lo = (frac_y - lo).abs(); + let d_hi = (frac_y - hi).abs(); + if d_lo < best_dist { + best_dist = d_lo; + target = Some(DragTarget::YLow); + } + if d_hi < best_dist { + // best_dist = d_hi; // not needed, last comparison + target = Some(DragTarget::YHigh); + } + } + + let _ = best_dist; + state.dragging = target; + let msg = on_click(frac_x, frac_y); + Some(canvas::Action::publish(msg).and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if state.dragging.is_some() { + let msg = on_click(frac_x, frac_y); + Some(canvas::Action::publish(msg).and_capture()) + } else { + None + } + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if state.dragging.take().is_some() { + Some(canvas::Action::request_redraw()) + } else { + None + } + } + _ => None, + } + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + + // Background. + let area = Path::rectangle(Point::ORIGIN, frame.size()); + frame.fill(&area, BG); + + let gx = if self.show_scales { + match (self.has_ch1, self.has_ch2) { + (true, true) => SCALE_MARGIN_LEFT * 2.0, + (true, false) | (false, true) => SCALE_MARGIN_LEFT, + (false, false) => 0.0, + } + } else { + 0.0 + }; + let gy = if self.show_scales { SCALE_MARGIN_BOTTOM } else { 0.0 }; + let gw = frame.width() - gx; + let gh = frame.height() - gy; + + draw_grid(&mut frame, bounds.size(), gx, gy); + + if let Some(cap) = self.capture { + // Draw traces for each enabled channel. + let channels = [ + (self.show_ch1, Some(&cap.ch1[..]) as Option<&[u8]>, CH1_COLOR), + (self.show_ch2, cap.ch2.as_deref(), CH2_COLOR), + ]; + for &(show, data, color) in &channels { + if let Some(samples) = show.then_some(data).flatten() { + draw_trace(&mut frame, samples, color, self.view_mode, gx, gh); + } + } + } else { + let text = Text { + content: crate::i18n::t!("app.no_capture_loaded").to_string(), + position: Point::new(gx + gw / 2.0, gh / 2.0), + color: Color::from_rgb(0.7, 0.7, 0.7), + size: 16.0.into(), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + ..Text::default() + }; + frame.fill_text(text); + } + + // Measurement cursors (user-controlled, with shaded range). + if let Some((xl, xr)) = self.meas_cursor_x { + let h = gh; + let px_l = gx + xl.clamp(0.0, 1.0) * gw; + let px_r = gx + xr.clamp(0.0, 1.0) * gw; + let region = Path::rectangle( + Point::new(px_l, 0.0), + Size::new((px_r - px_l).max(0.0), h), + ); + frame.fill(®ion, MEAS_FILL); + let stroke = Stroke::default().with_color(MEAS_CURSOR).with_width(1.0); + frame.stroke(&Path::line(Point::new(px_l, 0.0), Point::new(px_l, h)), stroke); + frame.stroke(&Path::line(Point::new(px_r, 0.0), Point::new(px_r, h)), stroke); + } + if let Some((yu, yl)) = self.meas_cursor_y { + let w = frame.width(); + let h = gh; + let py_u = yu.clamp(0.0, 1.0) * h; + let py_l = yl.clamp(0.0, 1.0) * h; + let region = Path::rectangle( + Point::new(0.0, py_u), + Size::new(w, (py_l - py_u).max(0.0)), + ); + frame.fill(®ion, MEAS_FILL); + let stroke = Stroke::default().with_color(MEAS_CURSOR).with_width(1.0); + frame.stroke(&Path::line(Point::new(0.0, py_u), Point::new(w, py_u)), stroke); + frame.stroke(&Path::line(Point::new(0.0, py_l), Point::new(w, py_l)), stroke); + } + + // Device cursors (read-only, dashed-look using thinner line, different colour). + let dev_stroke = Stroke::default().with_color(DEV_CURSOR).with_width(1.0); + if let Some((a, b)) = self.device_cursor_x { + let h = gh; + for x in [a, b] { + let px = gx + x.clamp(0.0, 1.0) * gw; + frame.stroke( + &Path::line(Point::new(px, 0.0), Point::new(px, h)), + dev_stroke, + ); + } + // Show time values next to device X cursors. + let total_time_ms = T_CELLS as f64 * self.t_per_div_ms; + let fmt_time = |frac: f32| -> String { + let t_ms = frac as f64 * total_time_ms; + format_duration_ms_scale(t_ms) + }; + frame.fill_text(dev_label(fmt_time(a), Point::new(gx + a.clamp(0.0, 1.0) * gw + 2.0, 12.0))); + frame.fill_text(dev_label(fmt_time(b), Point::new(gx + b.clamp(0.0, 1.0) * gw + 2.0, 12.0))); + // Also show Δt between cursors. + let dt_ms = (b - a).abs() as f64 * total_time_ms; + let dt_label = format!("Δt={}", format_duration_ms_scale(dt_ms)); + frame.fill_text(dev_label(dt_label, Point::new(gx + 4.0, 22.0))); + } + if let Some((a, b)) = self.device_cursor_y { + let w = frame.width(); + let h = gh; + for y in [a, b] { + let py = y.clamp(0.0, 1.0) * h; + frame.stroke( + &Path::line(Point::new(0.0, py), Point::new(w, py)), + dev_stroke, + ); + } + // Show numeric voltage values next to device Y cursors. + // Use the active channel's V/div and V_offset for correct voltage. + let (v_per_div, v_offset) = if self.active_channel == 0 { + (self.v_per_div_ch1, self.v_offset_ch1) + } else { + (self.v_per_div_ch2, self.v_offset_ch2) + }; + let cursor_to_voltage = |frac: f32| -> f64 { + // Match pixel_to_volts / draw_scales: v = frac × GRID_HEIGHT_PX + PX_PER_DIV + // so that frac=0 (top) maps to v=PX_PER_DIV, aligning with the grid labels. + let v = frac as f64 * GRID_HEIGHT_PX + PX_PER_DIV; + v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV + }; + let v_a = cursor_to_voltage(a); + let v_b = cursor_to_voltage(b); + frame.fill_text(dev_label(format!("{v_a:.2}V"), Point::new(gx + 4.0, a.clamp(0.0, 1.0) * h + 2.0))); + frame.fill_text(dev_label(format!("{v_b:.2}V"), Point::new(gx + 4.0, b.clamp(0.0, 1.0) * h + 2.0))); + } + + // Axis scales (sample numbers on X, voltage on Y per-channel) + if self.show_scales { + draw_scales( + &mut frame, + self.n_samples, + self.has_ch1, + self.has_ch2, + self.v_per_div_ch1, + self.v_per_div_ch2, + self.t_per_div_ms, + self.v_offset_ch1, + self.v_offset_ch2, + gx, + gh, + ); + } + + vec![frame.into_geometry()] + } +} + +fn draw_grid(frame: &mut Frame, size: Size, margin_left: f32, margin_bottom: f32) { + // 12 horizontal x 8 vertical divisions, like a real scope. + let cols = T_CELLS; + let rows = V_CELLS; + let gw = size.width - margin_left; + let dx = gw / cols as f32; + let graph_height = size.height - margin_bottom; + let dy = graph_height / rows as f32; + + let stroke = Stroke::default().with_color(GRID).with_width(1.0); + for i in 0..cols { + let x = margin_left + i as f32 * dx; + let p = Path::line(Point::new(x, 0.0), Point::new(x, graph_height)); + frame.stroke(&p, stroke); + } + for i in 1..rows+1 { + let y = i as f32 * dy; + let p = Path::line(Point::new(0.0, y), Point::new(size.width, y)); + frame.stroke(&p, stroke); + } + // Centerlines. + let axis = Stroke::default().with_color(GRID_AXIS).with_width(1.5); + let cx = margin_left + gw / 2.0; + let cy = graph_height / 2.0; + frame.stroke( + &Path::line(Point::new(cx, 0.0), Point::new(cx, graph_height)), + axis, + ); + frame.stroke( + &Path::line(Point::new(0.0, cy), Point::new(size.width, cy)), + axis, + ); +} + +/// Left margin reserved for voltage scale labels (pixels). +const SCALE_MARGIN_LEFT: f32 = 40.0; +const SCALE_MARGIN_BOTTOM: f32 = 15.0; + +const SCALE_COLOR: Color = Color::from_rgb(0.60, 0.65, 0.70); + +/// Number of vertical divisions on the oscilloscope screen for voltage. +pub const V_CELLS: usize = 8; +/// Number of horizontal divisions on the oscilloscope screen for time. +pub const T_CELLS: usize = 12; +/// Device pixel density: 25 px/division. Re-exported from dso_parser. +pub use dso_parser::PIXELS_PER_DIV as PX_PER_DIV; +/// Total pixels for the full grid height: V_CELLS × PX_PER_DIV. +pub const GRID_HEIGHT_PX: f64 = (V_CELLS * PX_PER_DIV as usize) as f64; + +#[allow(clippy::too_many_arguments)] +fn draw_scales( + frame: &mut Frame, + _n_samples: usize, + has_ch1: bool, + has_ch2: bool, + v_per_div_ch1: f64, + v_per_div_ch2: f64, + t_per_div_ms: f64, + v_offset_ch1: f64, + v_offset_ch2: f64, + margin_left: f32, + graph_height: f32, +) { + let size = frame.size(); + let rows = V_CELLS; + let cols = T_CELLS; // grid visual divisions + let gw = size.width - margin_left; + + // X axis: time labels at each visual division, in the bottom margin. + let total_time_ms = T_CELLS as f64 * t_per_div_ms; + let dx = gw / cols as f32; + for i in 0..=cols { + let frac = i as f64 / cols as f64; + let time_ms = frac * total_time_ms; + let x = margin_left + i as f32 * dx; + let content = format_duration_ms_scale(time_ms); + let label = Text { + content, + position: Point::new(x + 2.0, graph_height + 2.0), + color: SCALE_COLOR, + size: 9.0.into(), + ..Text::default() + }; + frame.fill_text(label); + } + + // Y axis: voltage scale per channel. + // Formula from pixel_to_volts: V = V_offset + (ADC_MID - v) × V/div / PIXELS_PER_DIV, + // where v = i × PX_PER_DIV is the stored value at grid line i. + let ch1_x_offset = 2.0; + let ch2_x_offset = if has_ch1 { 42.0 } else { 2.0 }; + + let mut label_ch = |v_per_div: f64, v_offset: f64, color: Color, x_off: f32| { + for i in 0..=rows { + let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; + let voltage = v_offset + (ADC_VALUE_MID as f64 - v) * v_per_div / PX_PER_DIV; + let y = (i as f32 / rows as f32) * graph_height; + let label = Text { + content: format!("{voltage:.2}V"), + position: Point::new(x_off, y + 2.0), + color, + size: 9.0.into(), + ..Text::default() + }; + frame.fill_text(label); + } + }; + + if has_ch1 { + label_ch(v_per_div_ch1, v_offset_ch1, CH1_COLOR, ch1_x_offset); + } + if has_ch2 { + label_ch(v_per_div_ch2, v_offset_ch2, CH2_COLOR, ch2_x_offset); + } +} + +fn draw_trace(frame: &mut Frame, samples: &[u8], color: Color, mode: ViewMode, margin_left: f32, graph_height: f32) { + if samples.is_empty() { + return; + } + let size = frame.size(); + let n = samples.len(); + let stroke = Stroke::default().with_color(color).with_width(1.2); + + // x maps sample i -> pixel; downsample so we never draw more vertices than pixels. + let gw = size.width - margin_left; + let max_pts = (gw as usize).max(2); + let step = n.div_ceil(max_pts).max(1); + + let value_at = |i: usize| -> f32 { + match mode { + ViewMode::Smooth => { + // Moving average with a small window relative to downsampling step. + let half = (step / 2).max(2); + let lo = i.saturating_sub(half); + let hi = (i + half + 1).min(n); + let sum: u32 = samples[lo..hi].iter().map(|&b| b as u32).sum(); + sum as f32 / (hi - lo) as f32 + } + _ => samples[i] as f32, + } + }; + + let to_xy = |i: usize, v: f32| -> Point { + let x = margin_left + (i as f32 / (n - 1).max(1) as f32) * gw; + // Capture values use screen-coordinate convention: low value = top + // of screen = positive voltage, high value = bottom = negative voltage. + // Use device pixel density: GRID_HEIGHT_PX = V_CELLS × PX_PER_DIV. + let y = (((v - PX_PER_DIV as f32) as f64 / GRID_HEIGHT_PX) * graph_height as f64) as f32; + Point::new(x, y) + }; + + match mode { + ViewMode::Dot => { + let r = 1.2; + for i in (0..n).step_by(step) { + let p = to_xy(i, value_at(i)); + let dot = Path::circle(p, r); + frame.fill(&dot, color); + } + } + ViewMode::Line | ViewMode::Smooth => { + let path = Path::new(|builder| { + let mut started = false; + for i in (0..n).step_by(step) { + let p = to_xy(i, value_at(i)); + if started { + builder.line_to(p); + } else { + builder.move_to(p); + started = true; + } + } + }); + frame.stroke(&path, stroke); + } + } +} + +// ── Thumbnail mini-scope ─────────────────────────────────────────────────── + +const THUMB_BG: Color = Color::from_rgb(0.06, 0.08, 0.12); +const THUMB_BORDER: Color = Color::from_rgb(0.30, 0.35, 0.42); +const THUMB_SELECTED: Color = Color::from_rgb(1.0, 0.30, 0.50); + +/// A tiny waveform thumbnail for the captures strip. +pub struct Thumbnail<'a> { + pub capture: &'a Capture, + pub selected: bool, +} + +impl canvas::Program for Thumbnail<'_> { + type State = (); + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let size = frame.size(); + + // Background + let bg = Path::rectangle(Point::ORIGIN, size); + frame.fill(&bg, THUMB_BG); + + // Border (highlight if selected) + let border_color = if self.selected { THUMB_SELECTED } else { THUMB_BORDER }; + let border = Path::rectangle(Point::ORIGIN, size); + frame.stroke(&border, Stroke::default().with_color(border_color).with_width(if self.selected { 2.0 } else { 1.0 })); + + // Draw miniature traces for each channel. + let mut draw_mini = |samples: &[u8], color: Color| { + if samples.is_empty() { + return; + } + let n = samples.len(); + let max_pts = (size.width as usize).max(2); + let step = n.div_ceil(max_pts).max(1); + let path = Path::new(|builder| { + let mut started = false; + for i in (0..n).step_by(step) { + let x = (i as f32 / (n - 1).max(1) as f32) * size.width; + let y = (((samples[i] as f64 - PX_PER_DIV) / GRID_HEIGHT_PX) * size.height as f64) as f32; + let p = Point::new(x, y); + if started { + builder.line_to(p); + } else { + builder.move_to(p); + started = true; + } + } + }); + frame.stroke(&path, Stroke::default().with_color(color).with_width(1.0)); + }; + + draw_mini(&self.capture.ch1, CH1_COLOR); + if let Some(ch2) = &self.capture.ch2 { + draw_mini(ch2, CH2_COLOR); + } + + vec![frame.into_geometry()] + } + + fn mouse_interaction( + &self, + _state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +// ── Time formatting utilities ────────────────────────────────────────────── + +/// Compact time-duration formatting for scale labels: ms with 0.1 precision, +/// µs/ns with .0. +pub fn format_duration_ms_scale(ms: f64) -> String { + if ms >= 1.0 { + format!("{ms:.1}ms") + } else if ms >= 0.001 { + format!("{:.0}µs", ms * 1000.0) + } else { + format!("{:.0}ns", ms * 1_000_000.0) + } +} diff --git a/gui/src/export_image.rs b/gui/src/export_image.rs new file mode 100644 index 0000000..fe34542 --- /dev/null +++ b/gui/src/export_image.rs @@ -0,0 +1,742 @@ +//! PNG image export for the oscilloscope capture viewer. +//! +//! Generates high-quality anti-aliased PNG images via `tiny-skia` for vector +//! paths (grid, traces) and renders text either with a built-in 3×5 bitmap +//! font or via system monospace fonts (`ab_glyph` + `font-kit`). + +use std::path::Path; + +use dso_parser::{ADC_VALUE_MID, Capture}; +use crate::canvas::format_duration_ms_scale; +use crate::canvas::{GRID_HEIGHT_PX, PX_PER_DIV, T_CELLS, V_CELLS}; +use crate::settings::{Settings, ViewMode}; + +// ── Colour palette (mirrors canvas.rs constants) ─────────────────────────── + +const PNG_BG: [u8; 3] = [10, 15, 26]; +const PNG_GRID: [u8; 3] = [51, 61, 77]; +const PNG_CH1: [u8; 3] = [255, 217, 25]; +const PNG_CH2: [u8; 3] = [242, 25, 242]; +const PNG_SCALE_TEXT: [u8; 3] = [153, 166, 179]; + +// ── Configuration ────────────────────────────────────────────────────────── + +/// Configuration for PNG export resolution and rendering quality. +pub struct PngExportConfig { + /// Target graph area width in pixels. 0 = default small (600 px). + pub target_width: u32, +} + +impl PngExportConfig { + fn scale(&self) -> f64 { + const BASE: u32 = T_CELLS as u32 * 50; + if self.target_width > 0 { + self.target_width as f64 / BASE as f64 + } else { + 1.0 + } + } + + fn graph_w(&self) -> u32 { + const BASE: u32 = T_CELLS as u32 * 50; + (BASE as f64 * self.scale()).round() as u32 + } + + fn graph_h(&self) -> u32 { + const BASE: u32 = V_CELLS as u32 * 50; + (BASE as f64 * self.scale()).round() as u32 + } + + fn scale_margin_left(&self, show: bool) -> u32 { + if show { + ((65.0 * self.scale()).round() as u32).max(52) + } else { + 0 + } + } + + fn scale_margin_bottom(&self, show: bool) -> u32 { + if show { (16.0 * self.scale()).round() as u32 } else { 0 } + } + + fn grid_width(&self) -> f32 { + (0.75 * self.scale().sqrt() as f32).clamp(0.75, 2.0) + } + + /// Full line width for the trace in pixels. + fn trace_width(&self) -> f32 { + (1.25 * self.scale().sqrt() as f32).clamp(1.0, 4.0) + } + + fn use_system_font(&self) -> bool { + self.target_width > 0 + } + + fn font_size(&self) -> f32 { + (11.0 * self.scale() as f32).max(11.0) + } +} + +// ── Main entry point ─────────────────────────────────────────────────────── + +/// Export the current capture as a PNG image. +pub fn export_png(path: &Path, cap: &Capture, settings: &Settings) -> anyhow::Result<()> { + let config = PngExportConfig { target_width: settings.png_export_width }; + + let graph_w = config.graph_w(); + let graph_h = config.graph_h(); + let scale_margin_left = config.scale_margin_left(settings.show_scales); + let scale_margin_bottom = config.scale_margin_bottom(settings.show_scales); + + let has_ch2 = cap.ch2.is_some(); + let do_split = settings.split_png && settings.show_ch1 && settings.show_ch2 && has_ch2; + + let width = graph_w + scale_margin_left; + let height = if do_split { + (graph_h + scale_margin_bottom) * 2 + } else { + graph_h + scale_margin_bottom + }; + + // RGBA pixel buffer for tiny-skia. + let mut pixels = vec![0u8; (width * height * 4) as usize]; + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&[PNG_BG[0], PNG_BG[1], PNG_BG[2], 255]); + } + + // Disable anti-aliasing at very low resolution to keep a crisp look. + let antialias = graph_w >= 400; + // Tie system-font choice to antialias: use the aliased bitmap glyph when + // anti-aliasing is off, so text stays crisp and fits the narrow margins. + let use_system_font = config.use_system_font() && antialias; + let font_size = config.font_size(); + let trace_w = config.trace_width(); + // When anti-aliasing is off, sub-pixel strokes may not render; ensure a + // minimum visible width. + let grid_w = if antialias { config.grid_width() } else { config.grid_width().max(1.0) }; + let smooth = settings.view_mode == ViewMode::Smooth; + let dot_mode = settings.view_mode == ViewMode::Dot; + let dot_radius = (trace_w * 0.6).max(1.0); + + // ── Grid helper via tiny-skia ──────────────────────────────────── + let draw_grid_on = |pixels: &mut [u8], y_off: u32| { + let Some(grid_path) = build_grid_path(scale_margin_left, y_off, graph_w, graph_h) else { + return; + }; + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, width, height) else { + return; + }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(PNG_GRID)); + paint.anti_alias = antialias; + let stroke = tiny_skia::Stroke { + width: grid_w, + ..tiny_skia::Stroke::default() + }; + pixmap.stroke_path(&grid_path, &paint, &stroke, tiny_skia::Transform::identity(), None); + }; + + // ── Scale labels ───────────────────────────────────────────────── + let t_per_div_ms = settings.t_per_div_ms; + let draw_scales_on_region = + |pixels: &mut [u8], x_off: u32, y_off: u32, w: u32, h: u32, _n_samples: usize, + vpd_ch1: f64, vo_ch1: f64, ch1_color: Option<[u8; 3]>, + vpd_ch2: f64, vo_ch2: f64, ch2_color: Option<[u8; 3]>| { + let cols = T_CELLS as u32; + let total_time_ms = T_CELLS as f64 * t_per_div_ms; + for i in 0..cols { + let frac = i as f64 / cols as f64; + let time_ms = frac * total_time_ms; + let label = format_duration_ms_scale(time_ms); + let x_pos = x_off + i * w / cols + 2; + let y_pos = y_off + h + 2; + draw_text_tiny(pixels, width, &label, x_pos, y_pos, PNG_SCALE_TEXT, use_system_font, font_size); + } + + // Y axis: voltage labels on left margin, per-channel. + let rows = V_CELLS as u32; + let mut draw_voltage_label = |vpd: f64, vo: f64, color: [u8; 3], x_lbl: u32| { + for i in 0..=rows { + let v = ((i + 1) as f64 / rows as f64) * GRID_HEIGHT_PX; + let voltage = vo + (ADC_VALUE_MID as f64 - v) * vpd / PX_PER_DIV; + let y_pos = y_off + (i * h / rows); + draw_text_tiny(pixels, width, &format!("{voltage:.1}"), x_lbl, y_pos, color, use_system_font, font_size); + } + }; + + let x_ch1 = 2u32; + let x_ch2 = if ch1_color.is_some() { (scale_margin_left as f64 * 0.4).round() as u32 } else { 2u32 }; + if let Some(c) = ch1_color { draw_voltage_label(vpd_ch1, vo_ch1, c, x_ch1); } + if let Some(c) = ch2_color { draw_voltage_label(vpd_ch2, vo_ch2, c, x_ch2); } + }; + + // ── Draw one subimage region ───────────────────────────────────── + let draw_one = |pixels: &mut [u8], samples: &[u8], trace_color: [u8; 3], + y_off: u32, vpd: f64, vo: f64, ch_col: Option<[u8; 3]>| { + draw_grid_on(pixels, y_off); + if !samples.is_empty() { + if dot_mode { + draw_dots(pixels, width, height, samples, trace_color, + scale_margin_left, y_off, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(pixels, width, height, samples, trace_color, + scale_margin_left, y_off, graph_w, graph_h, trace_w, smooth, antialias); + } + } + if let Some(c) = ch_col { + draw_scales_on_region( + pixels, scale_margin_left, y_off, graph_w, graph_h, samples.len(), + vpd, vo, Some(c), 0.0, 0.0, None, + ); + } + }; + + if do_split { + let ch1_col = settings.show_scales.then_some(PNG_CH1); + draw_one(&mut pixels, &cap.ch1, PNG_CH1, 0, + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col); + + let ch2_col = settings.show_scales.then_some(PNG_CH2); + if let Some(ch2) = &cap.ch2 { + draw_one(&mut pixels, ch2, PNG_CH2, graph_h + scale_margin_bottom, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col); + } + } else { + draw_grid_on(&mut pixels, 0); + if settings.show_ch1 { + if dot_mode { + draw_dots(&mut pixels, width, height, &cap.ch1, PNG_CH1, + scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(&mut pixels, width, height, &cap.ch1, PNG_CH1, + scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); + } + } + if settings.show_ch2 { + if let Some(ch2) = &cap.ch2 { + if dot_mode { + draw_dots(&mut pixels, width, height, ch2, PNG_CH2, + scale_margin_left, 0, graph_w, graph_h, dot_radius, antialias); + } else { + draw_trace(&mut pixels, width, height, ch2, PNG_CH2, + scale_margin_left, 0, graph_w, graph_h, trace_w, smooth, antialias); + } + } + } + if settings.show_scales { + let ch1_col = if settings.show_ch1 { Some(PNG_CH1) } else { None }; + let ch2_col = if settings.show_ch2 && has_ch2 { Some(PNG_CH2) } else { None }; + draw_scales_on_region( + &mut pixels, scale_margin_left, 0, graph_w, graph_h, cap.ch1.len(), + settings.v_per_div_ch1, settings.v_offset_ch1, ch1_col, + settings.v_per_div_ch2, settings.v_offset_ch2, ch2_col, + ); + } + } + + write_png(path, width, height, &pixels)?; + Ok(()) +} + +// ── tiny-skia helper functions ───────────────────────────────────────────── + +fn rgb_color(rgb: [u8; 3]) -> tiny_skia::Color { + tiny_skia::Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255) +} + +#[inline] +fn waveform_y(sample: f64, height: u32) -> f32 { + let y_scale = (height - 1) as f64 / GRID_HEIGHT_PX; + ((sample - PX_PER_DIV) * y_scale) as f32 +} + +#[inline] +fn is_visible_y(y: f32, height: u32) -> bool { + (0.0..=(height - 1) as f32).contains(&y) +} + +#[inline] +fn border_y(y: f32, height: u32) -> f32 { + if y < 0.0 { + 0.0 + } else { + (height - 1) as f32 + } +} + +/// Resample waveform samples into unclamped `(x, y)` points for the output +/// graph region. Keeping Y unclamped allows each renderer to distinguish real +/// border values from samples that are outside the visible voltage range. +/// +/// Upscaling uses linear interpolation. Downscaling preserves the min/max +/// envelope per output column. +fn resample_waveform( + samples: &[u8], + output_width: u32, + output_height: u32, +) -> Vec<(f32, f32)> { + let n = samples.len(); + if n == 0 || output_width == 0 || output_height == 0 { + return Vec::new(); + } + + // Keep the unclamped Y coordinate here. draw_trace() needs to know whether + // each resampled point is genuinely inside or outside the visible mesh. + let map_y = |sample: f64| waveform_y(sample, output_height); + + // Upscaling: linear interpolation between source samples. + if output_width as usize >= n { + let mut points = Vec::with_capacity(output_width as usize); + for x in 0..output_width { + let source_pos = x as f64 * (n - 1) as f64 / (output_width - 1).max(1) as f64; + let left = source_pos.floor() as usize; + let right = (left + 1).min(n - 1); + let t = source_pos - left as f64; + let sample = samples[left] as f64 * (1.0 - t) + samples[right] as f64 * t; + points.push((x as f32, map_y(sample))); + } + return points; + } + + // Downscaling: min/max envelope per column. + let mut points = Vec::with_capacity(output_width as usize * 2); + for x in 0..output_width { + let start = x as usize * n / output_width as usize; + let end = ((x + 1) as usize * n / output_width as usize) + .max(start + 1) + .min(n); + let col = &samples[start..end]; + let min = *col.iter().min().unwrap() as f64; + let max = *col.iter().max().unwrap() as f64; + let y_min = map_y(min); + let y_max = map_y(max); + + // Preserve temporal direction. + if col.first() <= col.last() { + points.push((x as f32, y_min)); + points.push((x as f32, y_max)); + } else { + points.push((x as f32, y_max)); + points.push((x as f32, y_min)); + } + } + points +} + +/// Build an anti-aliased grid path via tiny-skia. +fn build_grid_path( + x_off: u32, + y_off: u32, + width: u32, + height: u32, +) -> Option { + let mut path = tiny_skia::PathBuilder::new(); + for col in 1..T_CELLS as u32 { + let x = x_off as f32 + col as f32 * width as f32 / T_CELLS as f32; + path.move_to(x, y_off as f32); + path.line_to(x, (y_off + height) as f32); + } + for row in 1..V_CELLS as u32 { + let y = y_off as f32 + row as f32 * height as f32 / V_CELLS as f32; + path.move_to(x_off as f32, y); + path.line_to((x_off + width) as f32, y); + } + path.finish() +} + +/// Append a smooth Catmull–Rom curve segment (converted to cubic Bézier) +/// for each consecutive pair of points. +fn append_catmull_rom_path( + path: &mut tiny_skia::PathBuilder, + points: &[(f32, f32)], + x_off: u32, + y_off: u32, +) { + let ox = x_off as f32; + let oy = y_off as f32; + for i in 0..points.len() - 1 { + let p0 = if i == 0 { points[i] } else { points[i - 1] }; + let p1 = points[i]; + let p2 = points[i + 1]; + let p3 = if i + 2 < points.len() { points[i + 2] } else { p2 }; + // Catmull-Rom → cubic Bézier with tension 0.5. + let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0); + let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0); + path.cubic_to(ox + c1.0, oy + c1.1, ox + c2.0, oy + c2.1, ox + p2.0, oy + p2.1); + } +} + +/// Draw an anti-aliased trace via tiny-skia. +#[allow(clippy::too_many_arguments)] +fn draw_trace( + pixels: &mut [u8], + image_width: u32, + image_height: u32, + samples: &[u8], + color: [u8; 3], + x_off: u32, + y_off: u32, + width: u32, + height: u32, + line_width: f32, + smooth: bool, + antialias: bool, +) { + if samples.len() < 2 || width < 2 || height < 2 { + return; + } + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(pixels, image_width, image_height) else { + return; + }; + let points = resample_waveform(samples, width, height); + if points.len() < 2 { + return; + } + + // An outside point is retained only when it is adjacent to an inside + // point. In that case it is moved onto the corresponding top/bottom mesh + // border. Outside runs therefore remain hidden, while the visible line or + // smooth curve still reaches the border before disappearing/reappearing. + let clipped_points = points.iter().enumerate().map(|(i, &(x, y))| { + if is_visible_y(y, height) { + return Some((x, y)); + } + + let previous_inside = i > 0 && is_visible_y(points[i - 1].1, height); + let next_inside = i + 1 < points.len() + && is_visible_y(points[i + 1].1, height); + + (previous_inside || next_inside).then(|| (x, border_y(y, height))) + }); + + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(color)); + paint.anti_alias = antialias; + let stroke = tiny_skia::Stroke { + width: line_width, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..tiny_skia::Stroke::default() + }; + + let draw_run = |pixmap: &mut tiny_skia::PixmapMut<'_>, run: &[(f32, f32)]| { + if run.len() < 2 { + return; + } + + let mut path_builder = tiny_skia::PathBuilder::new(); + let first = run[0]; + path_builder.move_to(x_off as f32 + first.0, y_off as f32 + first.1); + + if smooth && run.len() >= 4 { + append_catmull_rom_path(&mut path_builder, run, x_off, y_off); + } else { + for &(x, y) in &run[1..] { + path_builder.line_to(x_off as f32 + x, y_off as f32 + y); + } + } + + if let Some(path) = path_builder.finish() { + pixmap.stroke_path( + &path, + &paint, + &stroke, + tiny_skia::Transform::identity(), + None, + ); + } + }; + + let mut run = Vec::new(); + for point in clipped_points { + match point { + Some(point) => run.push(point), + None => { + draw_run(&mut pixmap, &run); + run.clear(); + } + } + } + draw_run(&mut pixmap, &run); +} + +/// Draw dots via tiny-skia (Dot mode). Each visible sample is rendered as a +/// small filled circle. Samples outside the vertical mesh range are skipped; +/// unlike line/smooth modes, dot mode never creates synthetic border points. +/// +/// When anti-aliasing is disabled, dots are drawn as individual pixels +/// directly into the RGBA buffer — no scaling, no smoothing, no sub-pixel +/// artefacts — to keep the dot series crisp at low resolution. +#[allow(clippy::too_many_arguments)] +fn draw_dots( + pixels: &mut [u8], + image_width: u32, + image_height: u32, + samples: &[u8], + color: [u8; 3], + x_off: u32, + y_off: u32, + width: u32, + height: u32, + dot_radius: f32, + antialias: bool, +) { + let n = samples.len(); + if n == 0 || width < 2 || height < 2 { + return; + } + + let step = n.div_ceil(width as usize).max(1); + let sample_points = (0..n).step_by(step).filter_map(|i| { + let y = waveform_y(samples[i] as f64, height); + is_visible_y(y, height).then(|| { + let x = i as f64 / (n - 1).max(1) as f64 * width as f64; + (x as f32, y) + }) + }); + + if !antialias { + for (x, y) in sample_points { + let px = x_off + x.round() as u32; + let py = y_off + y.round() as u32; + let idx = (py as usize * image_width as usize + px as usize) * 4; + if let Some(pixel) = pixels.get_mut(idx..idx + 4) { + pixel[..3].copy_from_slice(&color); + pixel[3] = 255; + } + } + return; + } + + let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes( + pixels, + image_width, + image_height, + ) else { + return; + }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(rgb_color(color)); + paint.anti_alias = true; + + for (x, y) in sample_points { + let Some(circle) = tiny_skia::PathBuilder::from_circle( + x_off as f32 + x, + y_off as f32 + y, + dot_radius, + ) else { + continue; + }; + pixmap.fill_path( + &circle, + &paint, + tiny_skia::FillRule::Winding, + tiny_skia::Transform::identity(), + None, + ); + } +} + +// ── Text rendering ───────────────────────────────────────────────────────── + +/// Draw text onto a raw RGBA pixel buffer. +/// +/// Uses the built-in 3×5 bitmap font when `force_system_font` is false, +/// otherwise renders with the system monospace font via `ab_glyph` at the +/// given `font_size`. +#[allow(clippy::too_many_arguments)] +fn draw_text_tiny( + pixels: &mut [u8], + img_width: u32, + text: &str, + x: u32, + y: u32, + color: [u8; 3], + force_system_font: bool, + font_size: f32, +) { + if force_system_font { + draw_system_font(pixels, img_width, text, x, y, color, font_size); + } else { + draw_tiny_glyph(pixels, img_width, text, x, y, color); + } +} + +/// Render text with the built-in 3×5 bitmap font. Each font pixel maps to +/// exactly one output pixel — no scaling, no anti-aliasing. +fn draw_tiny_glyph(pixels: &mut [u8], img_width: u32, text: &str, x: u32, y: u32, color: [u8; 3]) { + let mut cx = x; + for ch in text.chars() { + let glyph = tiny_glyph(ch); + for row in 0..5u32 { + for col in 0..3u32 { + if glyph[row as usize] & (1 << (2 - col)) != 0 { + let px = cx + col; + let py = y + row; + let idx = ((py * img_width + px) * 4) as usize; + if idx + 3 < pixels.len() { + pixels[idx..idx + 3].copy_from_slice(&color); + pixels[idx + 3] = 255; + } + } + } + } + cx += 4; + } +} + +/// Render text using a system monospace font via `ab_glyph`. +/// Render text into an RGB pixel buffer using the operating system's +/// default monospace font family. +#[allow(clippy::too_many_arguments)] +fn draw_system_font( + pixels: &mut [u8], + img_width: u32, + text: &str, + x: u32, + y: u32, + color: [u8; 3], + font_size: f32, +) { + use ab_glyph::{Font, FontArc, PxScale, ScaleFont}; + use font_kit::{ + family_name::FamilyName, + properties::Properties, + source::SystemSource, + }; + use std::sync::OnceLock; + + /// Resolve the default system monospace family and convert it to an + /// `ab_glyph` font. + fn load_system_monospace() -> Option { + let handle = SystemSource::new() + .select_best_match( + &[FamilyName::Monospace], + &Properties::new(), + ) + .ok()?; + + // Extract font data and index from the handle, then load via ab_glyph. + let (font_data, font_index) = match &handle { + font_kit::handle::Handle::Memory { bytes, font_index } => { + ((**bytes).clone(), *font_index) + } + font_kit::handle::Handle::Path { path, font_index } => { + (std::fs::read(path).ok()?, *font_index) + } + }; + + let font = ab_glyph::FontVec::try_from_vec_and_index(font_data, font_index).ok()?; + Some(FontArc::new(font)) + } + + static FONT: OnceLock> = OnceLock::new(); + + let Some(font) = FONT.get_or_init(load_system_monospace).as_ref() else { + return; + }; + + let scaled_font = font.as_scaled(PxScale::from(font_size)); + let baseline = y as f32 + scaled_font.ascent(); + + let mut cursor_x = x as f32; + + for ch in text.chars() { + let glyph_id = font.glyph_id(ch); + + let glyph = glyph_id.with_scale_and_position( + font_size, + ab_glyph::point(cursor_x, baseline), + ); + + if let Some(outlined) = font.outline_glyph(glyph) { + let bounds = outlined.px_bounds(); + + outlined.draw(|local_x, local_y, coverage| { + let pixel_x = bounds.min.x.floor() as i32 + local_x as i32; + let pixel_y = bounds.min.y.floor() as i32 + local_y as i32; + + if pixel_x < 0 || pixel_y < 0 { + return; + } + + let pixel_x = pixel_x as u32; + let pixel_y = pixel_y as u32; + + if pixel_x >= img_width { + return; + } + + let index = + (pixel_y as usize * img_width as usize + pixel_x as usize) * 4; + + let Some(destination) = pixels.get_mut(index..index + 4) else { + return; + }; + + blend_rgb(destination, color, coverage); + }); + } + + cursor_x += scaled_font.h_advance(glyph_id); + } +} + +/// Alpha-blend one RGB color over an RGBA destination pixel. +fn blend_rgb(destination: &mut [u8], foreground: [u8; 3], alpha: f32) { + let alpha = alpha.clamp(0.0, 1.0); + let inverse_alpha = 1.0 - alpha; + + for channel in 0..3 { + destination[channel] = ( + foreground[channel] as f32 * alpha + + destination[channel] as f32 * inverse_alpha + ) + .round() + .clamp(0.0, 255.0) as u8; + } + destination[3] = 255; +} + +/// Returns a 5-row bitmask (3 bits wide) for a character. +fn tiny_glyph(ch: char) -> [u8; 5] { + match ch { + '0' => [0b111, 0b101, 0b101, 0b101, 0b111], + '1' => [0b010, 0b110, 0b010, 0b010, 0b111], + '2' => [0b111, 0b001, 0b111, 0b100, 0b111], + '3' => [0b111, 0b001, 0b111, 0b001, 0b111], + '4' => [0b101, 0b101, 0b111, 0b001, 0b001], + '5' => [0b111, 0b100, 0b111, 0b001, 0b111], + '6' => [0b111, 0b100, 0b111, 0b101, 0b111], + '7' => [0b111, 0b001, 0b001, 0b001, 0b001], + '8' => [0b111, 0b101, 0b111, 0b101, 0b111], + '9' => [0b111, 0b101, 0b111, 0b001, 0b111], + '.' => [0b000, 0b000, 0b000, 0b000, 0b010], + '-' => [0b000, 0b000, 0b111, 0b000, 0b000], + 'V' => [0b101, 0b101, 0b101, 0b101, 0b010], + 'm' => [0b101, 0b111, 0b101, 0b101, 0b101], + 's' => [0b011, 0b100, 0b010, 0b001, 0b110], + 'n' => [0b000, 0b110, 0b101, 0b101, 0b101], + 'µ' => [0b000, 0b101, 0b101, 0b111, 0b100], + ' ' => [0b000, 0b000, 0b000, 0b000, 0b000], + _ => [0b000, 0b000, 0b000, 0b000, 0b000], + } +} + +/// Write an RGBA pixel buffer as a PNG file using the `png` crate with +/// maximum compression (no quality loss). +fn write_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> anyhow::Result<()> { + use std::io::BufWriter; + + let file = std::fs::File::create(path)?; + let w = BufWriter::new(file); + + let mut encoder = png::Encoder::new(w, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_compression(png::Compression::Best); + + let mut writer = encoder.write_header()?; + writer.write_image_data(rgba)?; + Ok(()) +} diff --git a/gui/src/flash.rs b/gui/src/flash.rs new file mode 100644 index 0000000..8c0e683 --- /dev/null +++ b/gui/src/flash.rs @@ -0,0 +1,327 @@ +//! Firmware update module for the GUI. +//! +//! Reuses the XMODEM-1K protocol logic from `flasher/src/flash.rs`. +//! Runs the flash process in a background thread, reporting progress +//! via a channel. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crc::{Crc, CRC_16_XMODEM}; +use tokio::sync::mpsc; + +const DEFAULT_BAUDRATE: u32 = 115200; +const SYNC_THRESHOLD: usize = 5; + +const BAUDRATE_LIST: &[(u32, &[u8])] = &[ + ( + 2000000, + b"\x21\x0a\x00\xef\x2a\x31\x00\x00\x00\x80\x84\x1e\x00", + ), + ( + 1000000, + b"\x21\x0a\x00\x5e\x3d\x31\x00\x00\x00\x40\x42\x0f\x00", + ), + ( + 921600, + b"\x21\x0a\x00\x5d\x50\x31\x00\x00\x00\x00\x10\x0e\x00", + ), + ( + 460800, + b"\x21\x0a\x00\x07\x00\x31\x00\x00\x00\x00\x08\x07\x00", + ), + ( + 115200, + b"\x21\x0a\x00\x97\x4b\x31\x00\x00\x00\x00\xc2\x01\x00", + ), +]; + +const REBOOT_CMD: &[u8] = b"\x21\x06\x00\xc7\x7c\x3f\x00\x00\x00"; + +/// Progress events from the flash thread. +#[derive(Debug, Clone)] +pub enum FlashEvent { + Status(String), + Progress { current: u64, total: u64 }, + Finished(Result<(), String>), +} + +/// Handle to a running flash operation. +pub struct FlashHandle { + stop_flag: Arc, + thread: Option>, +} + +impl FlashHandle { + pub fn cancel(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + } +} + +impl Drop for FlashHandle { + fn drop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + if let Some(h) = self.thread.take() { + let _ = h.join(); + } + } +} + +/// Start the firmware update process in a background thread. +pub fn start_flash( + port_name: String, + firmware_path: String, + tx: mpsc::UnboundedSender, +) -> FlashHandle { + let stop = Arc::new(AtomicBool::new(false)); + let flag = stop.clone(); + + let thread = std::thread::spawn(move || { + let result = run_flash(&port_name, &firmware_path, &tx, &flag); + let _ = tx.send(FlashEvent::Finished(result.map_err(|e| e.to_string()))); + }); + + FlashHandle { + stop_flag: stop, + thread: Some(thread), + } +} + +fn run_flash( + port_name: &str, + firmware_path: &str, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) -> Result<(), anyhow::Error> { + let _ = tx.send(FlashEvent::Status(format!("Opening {port_name}…"))); + + let mut port = serialport::new(port_name, DEFAULT_BAUDRATE) + .timeout(Duration::from_secs(1)) + .open() + .map_err(|e| anyhow::anyhow!("Cannot open port: {e}"))?; + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Reset device + let _ = tx.send(FlashEvent::Status("Resetting device…".to_string())); + for _ in 0..10 { + reset_device(&mut *port)?; + send_esc(&mut *port, 20)?; + port.write_all(b"AT+RST\r\n")?; + send_esc(&mut *port, 20)?; + port.write_all(b"reboot\r\n")?; + port.flush()?; + send_esc(&mut *port, 50)?; + } + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Sync + let _ = tx.send(FlashEvent::Status("Syncing with device…".to_string())); + sync_device(&mut *port)?; + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Negotiate baud rate + let _ = tx.send(FlashEvent::Status("Negotiating baud rate…".to_string())); + for &(baud, cmd) in BAUDRATE_LIST { + if configure_baudrate(&mut *port, baud, cmd).is_ok() { + let _ = tx.send(FlashEvent::Status(format!("Using {baud} baud."))); + break; + } + } + + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + // Read firmware file + let file_data = std::fs::read(firmware_path) + .map_err(|e| anyhow::anyhow!("Cannot read firmware: {e}"))?; + let total_blocks = (file_data.len() as u64 / 1024) + 1; + + let _ = tx.send(FlashEvent::Status("Flashing…".to_string())); + + // XMODEM-1K send + xmodem1k_send(&mut *port, &file_data, total_blocks, tx, stop)?; + + // Reboot + let _ = tx.send(FlashEvent::Status("Rebooting device…".to_string())); + port.write_all(REBOOT_CMD)?; + port.flush()?; + + let _ = tx.send(FlashEvent::Status("Flash complete!".to_string())); + Ok(()) +} + +fn reset_device(port: &mut dyn serialport::SerialPort) -> Result<(), anyhow::Error> { + port.write_data_terminal_ready(false)?; + port.write_request_to_send(true)?; + std::thread::sleep(Duration::from_millis(10)); + port.write_data_terminal_ready(true)?; + port.write_request_to_send(false)?; + std::thread::sleep(Duration::from_millis(10)); + port.write_data_terminal_ready(false)?; + Ok(()) +} + +fn send_esc(port: &mut dyn serialport::SerialPort, ms: u64) -> Result<(), anyhow::Error> { + let delay = Duration::from_millis(10); + let iterations = ms / 10; + for _ in 0..iterations { + port.write_all(&[0x1B])?; + std::thread::sleep(delay); + } + Ok(()) +} + +fn sync_device(port: &mut dyn serialport::SerialPort) -> Result<(), anyhow::Error> { + let mut cnt = 0; + let start = Instant::now(); + let mut buf = [0; 1]; + + loop { + match port.read(&mut buf) { + Ok(1) => { + if buf[0] == b'C' { + cnt += 1; + } else { + cnt = 0; + } + if cnt >= SYNC_THRESHOLD { + return Ok(()); + } + } + _ => { + port.write_all(&[0x1B])?; + std::thread::sleep(Duration::from_millis(30)); + } + } + if start.elapsed() > Duration::from_secs(60) { + return Err(anyhow::anyhow!("Sync timeout — ensure device is in boot mode")); + } + } +} + +fn configure_baudrate( + port: &mut dyn serialport::SerialPort, + baud: u32, + cmd: &[u8], +) -> Result<(), anyhow::Error> { + port.set_baud_rate(DEFAULT_BAUDRATE)?; + if baud != DEFAULT_BAUDRATE { + port.set_baud_rate(DEFAULT_BAUDRATE)?; + std::thread::sleep(Duration::from_millis(20)); + } + port.write_all(cmd)?; + port.flush()?; + std::thread::sleep(Duration::from_millis(20)); + + // Drain + let mut drain = [0u8; 64]; + while port.bytes_to_read()? > 0 { + let _ = port.read(&mut drain); + } + + port.set_baud_rate(baud)?; + std::thread::sleep(Duration::from_millis(200)); + Ok(()) +} + +fn xmodem1k_send( + port: &mut dyn serialport::SerialPort, + data: &[u8], + total_blocks: u64, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) -> Result<(), anyhow::Error> { + let crc = Crc::::new(&CRC_16_XMODEM); + let mut block_num = 1u8; + let mut offset = 0; + + while offset < data.len() { + if stop.load(Ordering::Relaxed) { + return Err(anyhow::anyhow!("Cancelled")); + } + + let end = (offset + 1024).min(data.len()); + let mut buffer = [0u8; 1024]; + let chunk_len = end - offset; + buffer[..chunk_len].copy_from_slice(&data[offset..end]); + if chunk_len < 1024 { + buffer[chunk_len..].fill(0x1A); + } + + let checksum = crc.checksum(&buffer); + let mut success = false; + + for _ in 0..10 { + port.write_all(&[0x02, block_num, 255 - block_num])?; + port.write_all(&buffer)?; + port.write_all(&checksum.to_be_bytes())?; + port.flush()?; + + let mut ack = [0; 1]; + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + if let Ok(1) = port.read(&mut ack) { + if ack[0] == 0x06 { + success = true; + break; + } else if ack[0] == 0x15 { + break; + } + } + } + if success { + break; + } + } + + if !success { + return Err(anyhow::anyhow!("Failed to send block {block_num}")); + } + + let block_idx = (offset / 1024 + 1) as u64; + let _ = tx.send(FlashEvent::Progress { + current: block_idx, + total: total_blocks, + }); + block_num = block_num.wrapping_add(1); + offset += 1024; + } + + // Send EOT + port.write_all(&[0x04])?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baudrate_list_has_entries() { + assert!(!BAUDRATE_LIST.is_empty()); + } + + #[test] + fn baudrate_commands_are_13_bytes() { + for &(_, cmd) in BAUDRATE_LIST { + assert_eq!(cmd.len(), 13); + } + } + + #[test] + fn reboot_cmd_is_9_bytes() { + assert_eq!(REBOOT_CMD.len(), 9); + } +} diff --git a/gui/src/i18n.rs b/gui/src/i18n.rs new file mode 100644 index 0000000..1070a7a --- /dev/null +++ b/gui/src/i18n.rs @@ -0,0 +1,44 @@ +//! Tiny i18n layer. Locales live in `gui/locales/{en,ru,es,zh,hi}.json`. +//! Loaded at runtime via `rust-i18n`. Keys default to the English value. +//! +//! NB: the `rust_i18n::i18n!("locales", fallback = "en");` invocation lives +//! in `main.rs` (must be at the crate root for the macro to expand correctly). + +pub use rust_i18n::t; + + +pub fn set_language(lang: &str) { + rust_i18n::set_locale(lang); +} + +pub const LANGUAGES: &[(&str, &str, &str)] = &[ + ("en", "English", "English"), + ("ru", "Русский", "Russian"), + ("es", "Español", "Spanish"), + ("zh", "中文", "Chinese"), + ("hi", "हिन्दी", "Hindi"), +]; + +/// Returns display names for the language selector, including native names. +/// e.g. "Russian (Русский)", "English (English)" +pub fn language_display_names() -> Vec<(String, String)> { + LANGUAGES + .iter() + .map(|(code, native, english)| { + (code.to_string(), format!("{} ({})", english, native)) + }) + .collect() +} + +/// Detect the system locale and return a matching supported language code, +/// falling back to "en" if none matches. +pub fn detect_system_language() -> String { + if let Some(locale) = sys_locale::get_locale() { + // locale is like "en-US", "ru-RU", "zh-CN", "hi-IN", etc. + let lang = locale.split(['-', '_']).next().unwrap_or("en"); + if LANGUAGES.iter().any(|(code, _, _)| *code == lang) { + return lang.to_string(); + } + } + "en".into() +} diff --git a/gui/src/logging.rs b/gui/src/logging.rs new file mode 100644 index 0000000..9815c98 --- /dev/null +++ b/gui/src/logging.rs @@ -0,0 +1,114 @@ +//! File + stderr logging for the GUI. +//! +//! Logs go to `/dso3d12-gui/dso3d12-gui.log` (rotated naively when +//! it exceeds ~1 MB by being renamed to `.log.1`). Logs are also printed to +//! stderr at the level chosen by `RUST_LOG` (default `info`). + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; + +use log::{Level, LevelFilter, Metadata, Record}; + +pub fn log_path() -> Option { + let dir = dirs::config_dir()?.join("dso3d12-gui"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir.join("dso3d12-gui.log")) +} + +struct FileLogger { + file: Mutex>, + stderr_level: Level, +} + +impl log::Log for FileLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Trace + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let line = format!( + "[{ts}] {:5} {}: {}\n", + record.level(), + record.target(), + record.args() + ); + if record.level() <= self.stderr_level { + eprint!("{line}"); + } + if let Ok(mut guard) = self.file.lock() { + if let Some(f) = guard.as_mut() { + let _ = f.write_all(line.as_bytes()); + } + } + } + + fn flush(&self) { + if let Ok(mut g) = self.file.lock() { + if let Some(f) = g.as_mut() { + let _ = f.flush(); + } + } + } +} + +/// Initialize global logger. Safe to call multiple times; only the first call +/// installs the logger. +/// +/// By default only stderr logging is active (no file). Set `DSO_LOG_FILE=1` +/// to enable the file logger. +pub fn init() { + let stderr_level = std::env::var("RUST_LOG") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(Level::Info); + + let file = if std::env::var("DSO_LOG_FILE").is_ok() { + log_path().and_then(|p| { + rotate_if_needed(&p); + OpenOptions::new().create(true).append(true).open(&p).ok() + }) + } else { + None + }; + + let logger = FileLogger { + file: Mutex::new(file), + stderr_level, + }; + let _ = log::set_boxed_logger(Box::new(logger)) + .map(|()| log::set_max_level(LevelFilter::Trace)); +} + +fn rotate_if_needed(path: &PathBuf) { + const MAX_BYTES: u64 = 1_000_000; + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > MAX_BYTES { + let backup = path.with_extension("log.1"); + let _ = std::fs::rename(path, backup); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_path_returns_some() { + // On typical dev machines a config dir exists; just check the call + // doesn't panic. The result may be None inside sandboxes. + let _ = log_path(); + } + + #[test] + fn init_is_idempotent() { + init(); + init(); + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs new file mode 100644 index 0000000..28dc97d --- /dev/null +++ b/gui/src/main.rs @@ -0,0 +1,55 @@ +// `rust_i18n::i18n!` MUST be invoked at the crate root so that the generated +// helper symbol `_rust_i18n_t` is available to callers of `rust_i18n::t!`. +rust_i18n::i18n!("locales", fallback = "en"); + +mod app; +mod canvas; +mod export_image; +mod flash; +mod i18n; +mod logging; +mod range_slider; +mod serial; +mod settings; + +use app::App; +use iced::window; + +fn main() -> iced::Result { + logging::init(); + log::info!("starting dso3d12-gui"); + + // Set language: use persisted if non-empty, otherwise detect from system. + { + let settings = crate::settings::Settings::load(); + let lang = if settings.language.is_empty() { + let detected = crate::i18n::detect_system_language(); + log::info!("detected system language: {detected}"); + detected + } else { + settings.language.clone() + }; + crate::i18n::set_language(&lang); + } + + let icon = window::icon::from_file_data( + include_bytes!("../img/dso3d12_ico.png"), + None, + ) + .ok(); + + let settings = crate::settings::Settings::load(); + let (w, h) = settings.window_size; + + iced::application(App::new, App::update, App::view) + .title(App::title) + .theme(App::theme) + .subscription(App::subscription) + .window(window::Settings { + icon, + size: iced::Size::new(w, h), + min_size: Some(iced::Size::new(800.0, 500.0)), + ..Default::default() + }) + .run() +} diff --git a/gui/src/range_slider.rs b/gui/src/range_slider.rs new file mode 100644 index 0000000..93630e3 --- /dev/null +++ b/gui/src/range_slider.rs @@ -0,0 +1,282 @@ +//! A two-knob range slider widget for iced 0.14. +//! +//! Implemented as a `canvas::Program` over a 1-line track. The user drags the +//! knob nearest the mouse-down position. The slider emits `(low, high)` value +//! pairs (each in `0.0..=1.0`) via the supplied `on_change` callback. +#![allow(dead_code)] + +use iced::widget::canvas::{self, Frame, Geometry, Path, Stroke}; +use iced::{ + mouse, touch, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orientation { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, Default)] +enum Drag { + #[default] + None, + Low, + High, +} + +/// Mutable per-program state held by iced. +#[derive(Debug, Default)] +pub struct State { + drag: Drag, +} + +/// Range slider program. +pub struct RangeSlider { + pub low: f32, + pub high: f32, + pub orientation: Orientation, + pub on_change: Box Message>, +} + +impl RangeSlider { + pub fn horizontal( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, + ) -> Self { + Self { + low, + high, + orientation: Orientation::Horizontal, + on_change: Box::new(on_change), + } + } + + pub fn vertical( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, + ) -> Self { + Self { + low, + high, + orientation: Orientation::Vertical, + on_change: Box::new(on_change), + } + } +} + +const TRACK_COLOR: Color = Color::from_rgb(0.30, 0.34, 0.42); +const ACTIVE_COLOR: Color = Color::from_rgb(1.0, 0.30, 0.50); +const KNOB_COLOR: Color = Color::from_rgb(0.95, 0.95, 0.95); +const KNOB_RADIUS: f32 = 7.0; +const TRACK_THICKNESS: f32 = 4.0; + +impl canvas::Program for RangeSlider { + type State = State; + + fn update( + &self, + state: &mut Self::State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + let cursor_pos = cursor.position_in(bounds); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | canvas::Event::Touch(touch::Event::FingerPressed { .. }) => { + let pos = cursor_pos?; + // Pick the knob whose center is nearer along the active axis. + let v = self.value_from_cursor(pos, bounds.size()); + let d_low = (v - self.low).abs(); + let d_high = (v - self.high).abs(); + state.drag = if d_low <= d_high { Drag::Low } else { Drag::High }; + let action = self.publish_value(v, state); + Some(action.and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) + | canvas::Event::Touch(touch::Event::FingerMoved { .. }) => { + if matches!(state.drag, Drag::None) { + return None; + } + let pos = cursor_pos?; + let v = self.value_from_cursor(pos, bounds.size()); + Some(self.publish_value(v, state).and_capture()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | canvas::Event::Touch(touch::Event::FingerLifted { .. }) + | canvas::Event::Touch(touch::Event::FingerLost { .. }) => { + state.drag = Drag::None; + None + } + _ => None, + } + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let size = bounds.size(); + + match self.orientation { + Orientation::Horizontal => { + let cy = size.height / 2.0; + let track = Path::rectangle( + Point::new(0.0, cy - TRACK_THICKNESS / 2.0), + Size::new(size.width, TRACK_THICKNESS), + ); + frame.fill(&track, TRACK_COLOR); + + let xl = self.low.clamp(0.0, 1.0) * size.width; + let xh = self.high.clamp(0.0, 1.0) * size.width; + let active = Path::rectangle( + Point::new(xl, cy - TRACK_THICKNESS / 2.0), + Size::new((xh - xl).max(0.0), TRACK_THICKNESS), + ); + frame.fill(&active, ACTIVE_COLOR); + + self.draw_knob(&mut frame, Point::new(xl, cy)); + self.draw_knob(&mut frame, Point::new(xh, cy)); + } + Orientation::Vertical => { + let cx = size.width / 2.0; + let track = Path::rectangle( + Point::new(cx - TRACK_THICKNESS / 2.0, 0.0), + Size::new(TRACK_THICKNESS, size.height), + ); + frame.fill(&track, TRACK_COLOR); + + let yl = self.low.clamp(0.0, 1.0) * size.height; + let yh = self.high.clamp(0.0, 1.0) * size.height; + let (top, bot) = (yl.min(yh), yl.max(yh)); + let active = Path::rectangle( + Point::new(cx - TRACK_THICKNESS / 2.0, top), + Size::new(TRACK_THICKNESS, (bot - top).max(0.0)), + ); + frame.fill(&active, ACTIVE_COLOR); + + self.draw_knob(&mut frame, Point::new(cx, yl)); + self.draw_knob(&mut frame, Point::new(cx, yh)); + } + } + + vec![frame.into_geometry()] + } + + fn mouse_interaction( + &self, + state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if !matches!(state.drag, Drag::None) { + return mouse::Interaction::Grabbing; + } + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +impl RangeSlider { + fn value_from_cursor(&self, pos: Point, size: Size) -> f32 { + match self.orientation { + Orientation::Horizontal => (pos.x / size.width).clamp(0.0, 1.0), + Orientation::Vertical => (pos.y / size.height).clamp(0.0, 1.0), + } + } + + fn publish_value(&self, v: f32, state: &mut State) -> canvas::Action { + let (mut low, mut high) = (self.low, self.high); + match state.drag { + Drag::Low => { + low = v.min(self.high); + } + Drag::High => { + high = v.max(self.low); + } + Drag::None => return canvas::Action::request_redraw(), + } + canvas::Action::publish((self.on_change)(low, high)) + } + + fn draw_knob(&self, frame: &mut Frame, center: Point) { + let circle = Path::circle(center, KNOB_RADIUS); + frame.fill(&circle, KNOB_COLOR); + let outline = Path::circle(center, KNOB_RADIUS); + frame.stroke( + &outline, + Stroke::default().with_color(ACTIVE_COLOR).with_width(1.5), + ); + } +} + +/// Convenience wrapper: build a `canvas` widget of fixed size for this slider. +pub fn horizontal<'a, Message: Clone + 'a>( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, +) -> Element<'a, Message> { + iced::widget::canvas(RangeSlider::horizontal(low, high, on_change)) + .width(Length::Fill) + .height(Length::Fixed(22.0)) + .into() +} + +pub fn vertical<'a, Message: Clone + 'a>( + low: f32, + high: f32, + on_change: impl Fn(f32, f32) -> Message + 'static, +) -> Element<'a, Message> { + iced::widget::canvas(RangeSlider::vertical(low, high, on_change)) + .width(Length::Fixed(22.0)) + .height(Length::Fill) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rs() -> RangeSlider<(f32, f32)> { + RangeSlider::horizontal(0.3, 0.7, |a, b| (a, b)) + } + + #[test] + fn value_from_cursor_is_normalized() { + let s = rs(); + let v = s.value_from_cursor(Point::new(50.0, 11.0), Size::new(100.0, 22.0)); + assert!((v - 0.5).abs() < 1e-5); + } + + #[test] + fn value_from_cursor_clamps() { + let s = rs(); + let v = s.value_from_cursor(Point::new(-10.0, 11.0), Size::new(100.0, 22.0)); + assert_eq!(v, 0.0); + let v = s.value_from_cursor(Point::new(200.0, 11.0), Size::new(100.0, 22.0)); + assert_eq!(v, 1.0); + } + + #[test] + fn low_knob_cannot_exceed_high() { + let mut s = State { drag: Drag::Low }; + let rs = rs(); + let action = rs.publish_value(0.9, &mut s); + // Action carries Message via publish_value's internal callback; we verify + // the contract directly via the slider's clamp logic. + let _ = action; + // Direct check on logic: + let v = 0.9_f32.min(rs.high); + assert!(v <= rs.high); + } +} diff --git a/gui/src/serial.rs b/gui/src/serial.rs new file mode 100644 index 0000000..1b79b18 --- /dev/null +++ b/gui/src/serial.rs @@ -0,0 +1,229 @@ +//! Serial port communication for live capture from the DSO3D12. +//! +//! Runs a background thread that reads from the configured serial port using +//! the `serialport` crate. Received bytes are buffered; when no data arrives +//! for 500 ms we treat the buffered bytes as a complete capture and send it +//! to the GUI via a channel. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dso_parser::{parse_captures, is_screenshot_packet, Capture}; +use tokio::sync::mpsc; + +/// Configuration for serial capture. +#[derive(Debug, Clone)] +pub struct SerialConfig { + pub port: String, + pub baud_rate: u32, +} + +impl Default for SerialConfig { + fn default() -> Self { + Self { + port: String::new(), + baud_rate: 115200, + } + } +} + +/// Handle to a running serial listener. Drop or call `stop()` to end it. +pub struct SerialHandle { + stop_flag: Arc, + thread: Option>, +} + +impl SerialHandle { + pub fn stop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + if let Some(h) = self.thread.take() { + let _ = h.join(); + } + } +} + +impl Drop for SerialHandle { + fn drop(&mut self) { + self.stop(); + } +} + +/// Events emitted by the serial listener. +#[derive(Debug, Clone)] +pub enum SerialEvent { + /// A complete capture was received and parsed (ASCII debug dump). + CaptureReceived(Vec), + /// A binary screenshot packet was received (2048 bytes). + ScreenshotReceived(Vec), + /// Bytes received so far (progress indicator: total bytes buffered). + Progress(usize), + /// An error occurred (port disconnected, parse failure, etc.). + Error(String), + /// Port opened successfully. + Connected, + /// Port was disconnected; will attempt to reconnect. + Disconnected, +} + +/// List available serial ports on the system, filtered to relevant ones. +/// On macOS, only `tty.usbserial*` ports are shown (matching the flasher tool). +pub fn list_ports() -> Vec { + let ports = serialport::available_ports().unwrap_or_default(); + ports + .into_iter() + .map(|p| p.port_name) + .filter(|name| { + if cfg!(target_os = "macos") { + // On macOS, only show USB-serial adapters (tty.usbserial-*) + name.contains("tty.usbserial") + } else { + true + } + }) + .collect() +} + +/// Spawn a background thread that reads from the given serial port. +/// +/// Capture detection: when 500 ms passes with no new bytes, the accumulated +/// buffer is treated as a complete dump and parsed. Successfully parsed +/// captures are sent via the returned channel. +/// +/// If the port disconnects, the thread will retry every 2 seconds until +/// stopped. +pub fn start_listening( + config: SerialConfig, + tx: mpsc::UnboundedSender, +) -> SerialHandle { + let stop_flag = Arc::new(AtomicBool::new(false)); + let flag = stop_flag.clone(); + + let thread = std::thread::spawn(move || { + listener_loop(&config, &tx, &flag); + }); + + SerialHandle { + stop_flag, + thread: Some(thread), + } +} + +const CAPTURE_TIMEOUT: Duration = Duration::from_millis(500); +const RECONNECT_DELAY: Duration = Duration::from_secs(2); +const READ_TIMEOUT: Duration = Duration::from_millis(100); + +fn listener_loop( + config: &SerialConfig, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) { + while !stop.load(Ordering::Relaxed) { + match open_port(config) { + Ok(mut port) => { + let _ = tx.send(SerialEvent::Connected); + read_loop(&mut *port, tx, stop); + let _ = tx.send(SerialEvent::Disconnected); + } + Err(e) => { + log::warn!("cannot open {}: {e}", config.port); + let _ = tx.send(SerialEvent::Error(format!( + "Cannot open {}: {e}", + config.port + ))); + } + } + // Wait before reconnecting. + let deadline = Instant::now() + RECONNECT_DELAY; + while Instant::now() < deadline && !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +fn open_port(config: &SerialConfig) -> Result, serialport::Error> { + serialport::new(&config.port, config.baud_rate) + .data_bits(serialport::DataBits::Eight) + .stop_bits(serialport::StopBits::One) + .parity(serialport::Parity::None) + .flow_control(serialport::FlowControl::None) + .timeout(READ_TIMEOUT) + .open() +} + +fn read_loop( + port: &mut dyn serialport::SerialPort, + tx: &mpsc::UnboundedSender, + stop: &AtomicBool, +) { + let mut buf = [0u8; 4096]; + let mut accum: Vec = Vec::with_capacity(256_000); + let mut last_data = Instant::now(); + + while !stop.load(Ordering::Relaxed) { + match port.read(&mut buf) { + Ok(n) if n > 0 => { + accum.extend_from_slice(&buf[..n]); + last_data = Instant::now(); + let _ = tx.send(SerialEvent::Progress(accum.len())); + } + Ok(_) => {} + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { + // Normal — no data right now. + } + Err(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe + || e.kind() == std::io::ErrorKind::PermissionDenied => + { + log::warn!("serial port disconnected: {e}"); + return; // Will trigger reconnect. + } + Err(e) => { + log::error!("serial read error: {e}"); + return; + } + } + + // Check if we have buffered data and the timeout has elapsed. + if !accum.is_empty() && last_data.elapsed() >= CAPTURE_TIMEOUT { + log::info!("capture timeout; {} bytes buffered, parsing…", accum.len()); + // Detect format: screenshot binary (2034–2056 bytes) vs ASCII debug dump + if is_screenshot_packet(&accum) { + log::info!("detected screenshot packet ({} bytes)", accum.len()); + let _ = tx.send(SerialEvent::ScreenshotReceived(accum.clone())); + } else { + match parse_captures(&accum) { + Ok(captures) if !captures.is_empty() => { + let _ = tx.send(SerialEvent::CaptureReceived(captures)); + } + Ok(_) => { + let _ = tx.send(SerialEvent::Error( + "Received data but no valid captures found.".to_string(), + )); + } + Err(e) => { + let _ = tx.send(SerialEvent::Error(format!("Parse error: {e}"))); + } + } + } + accum.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_ports_does_not_panic() { + // May return empty on CI, but must not crash. + let _ = list_ports(); + } + + #[test] + fn serial_config_default() { + let cfg = SerialConfig::default(); + assert_eq!(cfg.baud_rate, 115200); + assert!(cfg.port.is_empty()); + } +} diff --git a/gui/src/settings.rs b/gui/src/settings.rs new file mode 100644 index 0000000..a7c8fd8 --- /dev/null +++ b/gui/src/settings.rs @@ -0,0 +1,157 @@ +//! Persisted user settings, stored as JSON under the OS config dir. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + /// Last-used serial port (persisted as default for next launch). + pub serial_port: Option, + pub baud_rate: u32, + pub show_ch1: bool, + pub show_ch2: bool, + /// Show user-controlled measurement cursors on the X axis. + pub measurement_cursor_x_enabled: bool, + /// Show user-controlled measurement cursors on the Y axis. + pub measurement_cursor_y_enabled: bool, + /// Show device-supplied cursors (if present in capture data). + pub device_cursor_x_enabled: bool, + pub device_cursor_y_enabled: bool, + pub view_mode: ViewMode, + /// X measurement cursor range (low, high), each 0.0..1.0 of sample window. + pub cursor_x_range: (f32, f32), + /// Y measurement cursor range (low, high), each 0.0..1.0 of vertical axis. + pub cursor_y_range: (f32, f32), + /// Show numeric axis scales on the graph. + pub show_scales: bool, + /// Split CH1/CH2 into separate subimages in PNG export. + pub split_png: bool, + /// Play a beep when serial data arrives. + pub alert_on_data: bool, + /// Automatically start listening when the configured port appears. + pub auto_listen: bool, + /// Volts per division for CH1. Default 1.0 V/div. + #[serde(default = "default_v_per_div")] + pub v_per_div_ch1: f64, + /// Volts per division for CH2. Default 1.0 V/div. + #[serde(default = "default_v_per_div")] + pub v_per_div_ch2: f64, + /// Time per division in milliseconds. Default 1.0 ms/div. + #[serde(default = "default_t_per_div_ms")] + pub t_per_div_ms: f64, + /// Voltage offset for CH1 (zero-level of graph) in volts. Default 0.0 V. + #[serde(default)] + pub v_offset_ch1: f64, + /// Voltage offset for CH2 (zero-level of graph) in volts. Default 0.0 V. + #[serde(default)] + pub v_offset_ch2: f64, + /// Selected UI language code (e.g. "en", "ru", "es", "zh", "hi"). + #[serde(default = "default_language")] + pub language: String, + /// Last window size (width, height) in logical pixels. + #[serde(default = "default_window_size")] + pub window_size: (f32, f32), + /// Target graph width for PNG export in pixels. 0 = small (600 px), 800+ = high-res. + #[serde(default = "default_png_width")] + pub png_export_width: u32, +} + +fn default_v_per_div() -> f64 { + 1.0 +} + +fn default_t_per_div_ms() -> f64 { + 1.0 +} + +fn default_language() -> String { + "en".to_string() +} + +fn default_window_size() -> (f32, f32) { + (800.0, 600.0) +} + +fn default_png_width() -> u32 { + 800 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ViewMode { + Line, + Dot, + Smooth, +} + +impl ViewMode { + pub const ALL: [ViewMode; 3] = [ViewMode::Line, ViewMode::Dot, ViewMode::Smooth]; +} + +impl std::fmt::Display for ViewMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ViewMode::Line => write!(f, "{}", crate::i18n::t!("label.view_mode_line")), + ViewMode::Dot => write!(f, "{}", crate::i18n::t!("label.view_mode_dot")), + ViewMode::Smooth => write!(f, "{}", crate::i18n::t!("label.view_mode_smooth")), + } + } +} + +impl Default for Settings { + fn default() -> Self { + Self { + serial_port: None, + baud_rate: 115200, + show_ch1: true, + show_ch2: true, + measurement_cursor_x_enabled: false, + measurement_cursor_y_enabled: false, + device_cursor_x_enabled: false, + device_cursor_y_enabled: false, + view_mode: ViewMode::Line, + cursor_x_range: (0.3, 0.7), + cursor_y_range: (0.3, 0.7), + show_scales: false, + split_png: false, + alert_on_data: true, + auto_listen: false, + v_per_div_ch1: 1.0, + v_per_div_ch2: 1.0, + t_per_div_ms: 1.0, + v_offset_ch1: 0.0, + v_offset_ch2: 0.0, + language: default_language(), + window_size: default_window_size(), + png_export_width: 800, + } + } +} + +pub fn config_dir_path() -> Option { + let dir = dirs::config_dir()?.join("dso3d12-gui"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +fn config_path() -> Option { + config_dir_path().map(|d| d.join("settings.json")) +} + +impl Settings { + pub fn load() -> Self { + let Some(path) = config_path() else { + return Self::default(); + }; + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn save(&self) { + let Some(path) = config_path() else { return }; + if let Ok(text) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, text); + } + } +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..653faaf --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,10 @@ +# Packaging Assets + +This directory contains auxiliary files used to assemble portable release artifacts. + +- `common/` holds files included in more than one operating system package. +- `linux/` holds Linux-specific packaging notes. +- `macos/` holds macOS bundle metadata such as the `Info.plist` template. +- `windows/` holds Windows-specific packaging notes. + +The release workflow in `.github/workflows/ci.yaml` consumes these files directly. diff --git a/packaging/common/README-cli.txt b/packaging/common/README-cli.txt new file mode 100644 index 0000000..20a5cba --- /dev/null +++ b/packaging/common/README-cli.txt @@ -0,0 +1,12 @@ +DSO3D12 Flasher CLI package + +Contents: +- `flash` executable for this operating system (firmware flasher) +- `capture` executable (serial capture tool) +- `LICENSE.txt` +- `README.md` + +Usage: +- Run `flash --help` to see firmware flashing commands. +- Run `capture --help` to see serial capture commands. +- USB serial access is required to communicate with the oscilloscope. diff --git a/packaging/common/README-gui.txt b/packaging/common/README-gui.txt new file mode 100644 index 0000000..2d048b8 --- /dev/null +++ b/packaging/common/README-gui.txt @@ -0,0 +1,10 @@ +DSO3D12 GUI package + +Contents: +- `dso3d12-gui` graphical application for this operating system +- `LICENSE.txt` +- `README.md` + +Notes: +- On macOS the application is distributed as a .app bundle. +- USB serial access is required to communicate with the oscilloscope. diff --git a/packaging/linux/README.txt b/packaging/linux/README.txt new file mode 100644 index 0000000..c20e0d7 --- /dev/null +++ b/packaging/linux/README.txt @@ -0,0 +1,5 @@ +Linux packaging notes + +- Archives are produced as zip files for parity with the other operating systems. +- The executable can be run directly after extracting the archive. +- Ensure your user has permission to access USB serial ports (e.g. in the `dialout` group). diff --git a/packaging/macos/Info.plist.template b/packaging/macos/Info.plist.template new file mode 100644 index 0000000..2f238b4 --- /dev/null +++ b/packaging/macos/Info.plist.template @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + __APP_NAME__ + CFBundleExecutable + dso3d12-gui + CFBundleIconFile + icon + CFBundleIdentifier + __IDENTIFIER__ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + __APP_NAME__ + CFBundlePackageType + APPL + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 10.13 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/packaging/macos/README-unsigned.txt b/packaging/macos/README-unsigned.txt new file mode 100644 index 0000000..197faba --- /dev/null +++ b/packaging/macos/README-unsigned.txt @@ -0,0 +1,48 @@ +DSO3D12 GUI — macOS Unsigned App Instructions + +This package contains an unsigned macOS application bundle (.app). +The application has NOT been signed with an Apple Developer Certificate. + +=== Installing the Application === + +1. Extract DSO3D12.app from the zip archive (or mount the DMG) +2. Drag DSO3D12.app to your Applications folder + +=== Running the Application for the First Time === + +macOS will warn about the unsigned application. To allow it to run: + +METHOD 1: Right-click in Finder (Recommended) + 1. Open Finder and go to Applications + 2. Right-click on DSO3D12.app + 3. Click "Open" (not just double-click) + 4. Click "Open" in the confirmation dialog + +METHOD 2: Command Line (Terminal) + 1. Open Terminal + 2. Run: xattr -d com.apple.quarantine /Applications/DSO3D12.app + 3. Then double-click the app normally + +METHOD 3: System Settings + 1. Open System Settings → Privacy & Security + 2. Scroll down to look for "DSO3D12.app was blocked..." + 3. Click "Open Anyway" + 4. Double-click the app + +=== Running from Terminal === + +You can also run the application from the terminal: + /Applications/DSO3D12.app/Contents/MacOS/dso3d12-gui + +=== Troubleshooting === + +If the app won't open: +- Ensure you've allowed it through System Settings +- Try the xattr command from METHOD 2 above +- Restart your Mac + +If the serial port is not detected: +- Check that the oscilloscope is connected via USB +- Verify the USB-serial driver is installed (CH340/CP210x) + +For more information, see the main README.md file. diff --git a/packaging/macos/create_app_bundle.sh b/packaging/macos/create_app_bundle.sh new file mode 100755 index 0000000..47b8944 --- /dev/null +++ b/packaging/macos/create_app_bundle.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# Script to create a macOS .app bundle for DSO3D12 GUI. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CREATE_DMG=false +APP_NAME="DSO3D12" +BUNDLE_NAME="DSO3D12.app" +DMG_NAME="DSO3D12.dmg" +IDENTIFIER="com.dso3d12.gui" +VERSION="0.1.0" +ASSETS_DIR="$SCRIPT_DIR" +ICON_SOURCE="$PROJECT_ROOT/gui/img/dso3d12_ico.png" +OUTPUT_DIR="$PROJECT_ROOT" +BINARY_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) + CREATE_DMG=true + shift + ;; + --binary-path) + BINARY_PATH="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --assets-dir) + ASSETS_DIR="$2" + shift 2 + ;; + --icon) + ICON_SOURCE="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$BINARY_PATH" ]]; then + BINARY_PATH="$PROJECT_ROOT/target/release/dso3d12-gui" +fi + +INFO_TEMPLATE="$ASSETS_DIR/Info.plist.template" +APP_DIR="$OUTPUT_DIR/$BUNDLE_NAME" +DMG_PATH="$OUTPUT_DIR/$DMG_NAME" + +if [[ ! -f "$BINARY_PATH" ]]; then + echo "Binary not found: $BINARY_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$INFO_TEMPLATE" ]]; then + echo "Info.plist template not found: $INFO_TEMPLATE" >&2 + exit 1 +fi + +if [[ ! -f "$ICON_SOURCE" ]]; then + echo "Icon source not found: $ICON_SOURCE" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +echo "Creating macOS app bundle: $APP_DIR" + +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" +mkdir -p "$APP_DIR/Contents/Resources" + +echo "Copying binary..." +cp "$BINARY_PATH" "$APP_DIR/Contents/MacOS/dso3d12-gui" +chmod +x "$APP_DIR/Contents/MacOS/dso3d12-gui" + +echo "Converting icon to PNG (512px)..." +sips -s format png "$ICON_SOURCE" --out "$APP_DIR/Contents/Resources/icon.png" --resampleWidth 512 > /dev/null 2>&1 + +echo "Creating icon set..." +ICONSET="$APP_DIR/Contents/Resources/icon.iconset" +mkdir -p "$ICONSET" + +sips -z 16 16 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_16x16.png" > /dev/null 2>&1 +sips -z 32 32 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_16x16@2x.png" > /dev/null 2>&1 +sips -z 32 32 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_32x32.png" > /dev/null 2>&1 +sips -z 64 64 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_32x32@2x.png" > /dev/null 2>&1 +sips -z 128 128 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_128x128.png" > /dev/null 2>&1 +sips -z 256 256 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_128x128@2x.png" > /dev/null 2>&1 +sips -z 256 256 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_256x256.png" > /dev/null 2>&1 +sips -z 512 512 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_256x256@2x.png" > /dev/null 2>&1 +sips -z 512 512 "$APP_DIR/Contents/Resources/icon.png" --out "$ICONSET/icon_512x512.png" > /dev/null 2>&1 +cp "$APP_DIR/Contents/Resources/icon.png" "$ICONSET/icon_512x512@2x.png" + +echo "Converting to .icns format..." +iconutil -c icns "$ICONSET" -o "$APP_DIR/Contents/Resources/icon.icns" + +rm -rf "$ICONSET" +rm "$APP_DIR/Contents/Resources/icon.png" + +echo "Creating Info.plist..." +sed \ + -e "s|__APP_NAME__|$APP_NAME|g" \ + -e "s|__IDENTIFIER__|$IDENTIFIER|g" \ + -e "s|__VERSION__|$VERSION|g" \ + "$INFO_TEMPLATE" > "$APP_DIR/Contents/Info.plist" + +echo "" +echo "App bundle created successfully: $APP_DIR" +echo "" + +if [[ "$CREATE_DMG" == true ]]; then + echo "Creating DMG disk image..." + rm -f "$DMG_PATH" + hdiutil create -volname "$APP_NAME" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" + echo "" + echo "DMG created: $DMG_PATH" + echo "" +else + echo "To run the app:" + echo " open $APP_DIR" + echo "" + echo "To create a DMG for distribution:" + echo " ./create_app_bundle.sh --dmg" + echo "" +fi diff --git a/packaging/windows/README.txt b/packaging/windows/README.txt new file mode 100644 index 0000000..7782e5d --- /dev/null +++ b/packaging/windows/README.txt @@ -0,0 +1,5 @@ +Windows packaging notes + +- Archives are produced as zip files containing the `.exe` and supporting files. +- Extract the full archive before launching the application. +- You may need to install USB serial drivers for the oscilloscope connection.