mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60c41c2c8 | ||
|
|
240aa848a0 | ||
|
|
7414719f37 | ||
|
|
814ae99a32 | ||
|
|
0434bb1397 | ||
|
|
7c64b27753 | ||
|
|
5589d97257 | ||
|
|
63b487589c |
@@ -24,27 +24,5 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
@@ -19,49 +19,5 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/soundcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"]
|
||||
assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio"
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
+22
-427
@@ -1,11 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "SoundCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
|
||||
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
|
||||
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
|
||||
# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them
|
||||
# (a dry run); the signed jobs are refused there, and so the draft release is skipped.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -19,429 +11,32 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
SOUNDCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets (and gates every other job).
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
libasound2-dev pkg-config \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications'
|
||||
ldd target/release/soundcraft
|
||||
# cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
|
||||
ldd target/release/soundcraft | grep -q libasound
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI (which links no ALSA) is smoke-tested under QEMU. Plugin hosting (CLAP, VST3) is
|
||||
# pure-Rust bindings plus dlopen, so it builds here unchanged; cpal uses ALSA (no JACK).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 twins of the linux job's -dev packages; libasound2-dev is cpal's ALSA backend.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
pkg-config file desktop-file-utils appstream \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
file target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'RISC-V'
|
||||
# cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
|
||||
riscv64-linux-gnu-readelf -d target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'libasound'
|
||||
tar -tzf "dist/release/soundcraft-$SOUNDCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E 'bin/soundcraft$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA"
|
||||
# alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime).
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts.
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="SoundCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -26,70 +26,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: soundcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: soundcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'SoundCraft'
|
||||
foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'soundcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok soundcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
|
||||
Generated
+10
@@ -782,6 +782,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -3570,6 +3579,7 @@ dependencies = [
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
|
||||
@@ -41,6 +41,7 @@ rustfft = "6"
|
||||
cpal = "0.16"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
|
||||
@@ -137,7 +137,8 @@ where things are.
|
||||
## Getting started
|
||||
|
||||
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
|
||||
from source with a recent stable Rust:
|
||||
from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
|
||||
minimum remains Rust 1.90):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/soundcraft
|
||||
@@ -373,3 +374,5 @@ Forks and modified versions must remove them.
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
|
||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
|
||||
|
||||
@@ -299,7 +299,7 @@ fn app(args: &[String]) -> ExitCode {
|
||||
let positional: Vec<&String> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))
|
||||
.filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))))
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Per-user settings and automation profile directories.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
Mac,
|
||||
Windows,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
Platform::Windows
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
|
||||
Platform::Other => {
|
||||
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
|
||||
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
|
||||
read_ui(path, true)
|
||||
}
|
||||
|
||||
fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
|
||||
use std::io::Read as _;
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("Settings could not be read: {error}")),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_UI_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("Settings could not be read: {error}"))?;
|
||||
if bytes.len() as u64 > MAX_UI_BYTES {
|
||||
return Err("Settings file exceeds the 4 MiB limit".into());
|
||||
}
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
if recover_docking && let Some(fields) = saved.as_object_mut() {
|
||||
for (field, mix) in [("docking", false), ("mix_docking", true)] {
|
||||
let unreadable =
|
||||
fields.get(field).is_some_and(|value| match serde_json::from_value::<soundcraft_ui_egui::docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
|
||||
Err(_) => true,
|
||||
});
|
||||
if unreadable {
|
||||
fields.remove(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
state.docking.validate()?;
|
||||
state.mix_docking.validate_mix()?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
const MAX_UI_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// Write settings atomically. Preserve an unreadable or malformed existing file for recovery.
|
||||
pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> {
|
||||
use std::io::Write as _;
|
||||
// Loading can recover a future layout for this session, but saving must preserve its source.
|
||||
read_ui(path, false)?;
|
||||
ui.docking.validate()?;
|
||||
ui.mix_docking.validate_mix()?;
|
||||
let mut saved = ui.clone();
|
||||
saved.plugin_windows.clear();
|
||||
saved.status.clear();
|
||||
let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
|
||||
if bytes.len() as u64 > MAX_UI_BYTES {
|
||||
return Err("Settings exceed the 4 MiB limit".into());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|error| error.to_string())?.as_nanos();
|
||||
let temporary = path.with_extension(format!("json.{}.{nonce}.tmp", std::process::id()));
|
||||
let mut file =
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|error| format!("Settings could not be saved: {error}"))?;
|
||||
let written = (|| -> std::io::Result<()> {
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&temporary, path)
|
||||
})();
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
}
|
||||
written.map_err(|error| format!("Settings could not be saved: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() {
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel};
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-dock-prefs-{}", std::process::id()));
|
||||
let path = directory.join("ui.json");
|
||||
let _ = std::fs::remove_dir_all(&directory);
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"close","panel":"tracks"})).unwrap();
|
||||
app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light;
|
||||
let edit = serde_json::to_value(&app.ui.docking).unwrap();
|
||||
app.ui.window = MainWindow::Mix;
|
||||
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[140,150,280,420]})).unwrap();
|
||||
save_ui(&path, &app.ui).unwrap();
|
||||
app.ui = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
|
||||
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
app.ui.window = MainWindow::Edit;
|
||||
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
let mut invalid_mix = app.ui.clone();
|
||||
invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
|
||||
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
|
||||
std::fs::write(&path, &invalid_bytes).unwrap();
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
assert!(save_ui(&path, &app.ui).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
|
||||
std::fs::write(&path, b"damaged settings").unwrap();
|
||||
assert!(load_ui(&path).is_err());
|
||||
assert!(save_ui(&path, &app.ui).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"damaged settings");
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_edit_and_mix_layouts_preserve_other_preferences_and_the_original_file() {
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-future-dock-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let path = directory.join("ui.json");
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
|
||||
app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light;
|
||||
app.ui.narrow_mix = true;
|
||||
for field in ["docking", "mix_docking"] {
|
||||
for invalid in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
|
||||
let mut saved = serde_json::to_value(&app.ui).unwrap();
|
||||
saved[field] = invalid;
|
||||
assert!(serde_json::from_value::<soundcraft_ui_egui::UiState>(saved.clone()).is_err());
|
||||
let bytes = serde_json::to_vec(&saved).unwrap();
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert!(recovered.narrow_mix);
|
||||
recovered.docking.validate().unwrap();
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
let other = if field == "docking" { "mix_docking" } else { "docking" };
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
|
||||
assert!(save_ui(&path, &recovered).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_settings_are_rejected_and_preserved() {
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let path = directory.join("ui.json");
|
||||
let bytes = vec![b' '; MAX_UI_BYTES as usize + 1];
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
assert!(load_ui(&path).is_err());
|
||||
assert!(save_ui(&path, &soundcraft_ui_egui::UiState::default()).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_unset_override_keeps_platform_defaults() {
|
||||
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
|
||||
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/SoundCraft")));
|
||||
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/SoundCraft")));
|
||||
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/soundcraft")));
|
||||
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/soundcraft")));
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(resolve(platform, &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
+16
-24
@@ -11,6 +11,7 @@
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod config;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
@@ -97,16 +98,7 @@ fn open_path(app: &mut SoundApp, p: &str) {
|
||||
|
||||
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("soundcraft"))
|
||||
}
|
||||
config::config_dir()
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
@@ -122,24 +114,24 @@ fn load_prefs() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
let path = prefs_path()?;
|
||||
match config::load_ui(&path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
log::warn!("{error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_prefs(ui: &UiState) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let mut ui = ui.clone();
|
||||
ui.plugin_windows.clear();
|
||||
ui.status.clear();
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
if let Some(path) = prefs_path()
|
||||
&& let Err(error) = config::save_ui(&path, ui)
|
||||
{
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +190,7 @@ fn main() -> eframe::Result {
|
||||
let files: Vec<String> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
|
||||
.filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))))
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect();
|
||||
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
@@ -215,7 +207,7 @@ fn main() -> eframe::Result {
|
||||
},
|
||||
None => {
|
||||
logger.no_file();
|
||||
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
|
||||
log::warn!("no log file: no settings directory (profile override is empty or platform configuration is unavailable)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ impl MixEngine {
|
||||
// through any track, send or insert. Only the playback engine sets `metronome`.
|
||||
// Not while monitoring only (transport stopped): the position does not advance there, so the
|
||||
// start of the same beat would repeat every block as a buzz.
|
||||
if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) {
|
||||
if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) {
|
||||
click::render(s, pos, frames, &mut self.main);
|
||||
}
|
||||
// Output: min(out.len(), main channels) channels; any further output channels are silent.
|
||||
|
||||
@@ -4,10 +4,11 @@ description = "SoundCraft's egui user interface: Edit and Mix windows, transport
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
rust-version = "1.95"
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui.workspace = true
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Panel {
|
||||
Canvas,
|
||||
Tracks,
|
||||
Clips,
|
||||
Midi,
|
||||
Memory,
|
||||
History,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "canvas",
|
||||
Self::Tracks => "tracks",
|
||||
Self::Clips => "clips",
|
||||
Self::Midi => "midi",
|
||||
Self::Memory => "memory",
|
||||
Self::History => "history",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "Session",
|
||||
Self::Tracks => "Tracks and Groups",
|
||||
Self::Clips => "Clips",
|
||||
Self::Midi => "MIDI Editor",
|
||||
Self::Memory => "Memory Locations",
|
||||
Self::History => "Undo History",
|
||||
}
|
||||
}
|
||||
fn default_side(self) -> Zone {
|
||||
match self {
|
||||
Self::Canvas => Zone::Right,
|
||||
Self::Tracks => Zone::Left,
|
||||
Self::Clips => Zone::Right,
|
||||
Self::Midi => Zone::Bottom,
|
||||
Self::Memory => Zone::Bottom,
|
||||
Self::History => Zone::Right,
|
||||
}
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
const MIDI_TAB_HEIGHT: f32 = 26.0;
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
Node::Tabs { panels: vec![panel], active: 0 }
|
||||
}
|
||||
|
||||
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Vertical,
|
||||
SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(168.0),
|
||||
tabs(Panel::Tracks),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)),
|
||||
),
|
||||
tabs(Panel::Midi),
|
||||
)),
|
||||
floating: vec![
|
||||
craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] },
|
||||
craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] },
|
||||
],
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_mix(&self) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(saved) = &mut location
|
||||
&& saved.floating.is_some()
|
||||
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
|
||||
{
|
||||
// The other members of a floating group may have been closed too.
|
||||
saved.anchor = None;
|
||||
saved.placement = Placement::Tab { before: None };
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.set_visible(panel, true),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit.
|
||||
pub fn mix_default() -> Workspace {
|
||||
let mut workspace = Workspace::default();
|
||||
let _ = workspace.set_visible(Panel::Clips, false);
|
||||
let _ = workspace.set_visible(Panel::Midi, false);
|
||||
workspace
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> {
|
||||
workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?;
|
||||
workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?;
|
||||
workspace.set_visible(Panel::History, app.ui.show_undo_history)?;
|
||||
if edit {
|
||||
workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?;
|
||||
workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_visibility(app: &mut crate::SoundApp, workspace: &Workspace, edit: bool) {
|
||||
app.ui.show_tracks_list = workspace.layout.contains(&Panel::Tracks);
|
||||
app.ui.show_memory_locations = workspace.layout.contains(&Panel::Memory);
|
||||
app.ui.show_undo_history = workspace.layout.contains(&Panel::History);
|
||||
if edit {
|
||||
app.ui.show_clip_list = workspace.layout.contains(&Panel::Clips);
|
||||
app.ui.show_midi_editor = workspace.layout.contains(&Panel::Midi);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) {
|
||||
if edit {
|
||||
app.ui.docking = workspace;
|
||||
} else {
|
||||
app.ui.mix_docking = workspace;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result<Value, String> {
|
||||
let edit = app.ui.window == crate::MainWindow::Edit;
|
||||
let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
|
||||
let result = command_for(app, reset, params, edit)?;
|
||||
let next = if edit { &app.ui.docking } else { &app.ui.mix_docking };
|
||||
cancel_replaced_workspace(app, &previous, next, edit);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) {
|
||||
if (previous.layout != next.layout || previous.hidden != next.hidden)
|
||||
&& let Some(ctx) = &app.docking_context
|
||||
{
|
||||
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::new(("soundcraft-docking", edit)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Restoring complete preferences/configurations uses the same ownership boundary as commands.
|
||||
pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState) {
|
||||
cancel_replaced_workspace(app, &app.ui.docking, &next.docking, true);
|
||||
cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false);
|
||||
}
|
||||
|
||||
fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result<Value, String> {
|
||||
if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
|
||||
if !reset && !edit {
|
||||
workspace.validate_mix()?;
|
||||
}
|
||||
if reset {
|
||||
workspace = if edit { Workspace::default() } else { mix_default() };
|
||||
sync(&mut workspace, app, edit)?;
|
||||
} else {
|
||||
sync(&mut workspace, app, edit)?;
|
||||
workspace.command(params)?;
|
||||
if !edit {
|
||||
workspace.validate_mix()?;
|
||||
}
|
||||
}
|
||||
publish_visibility(app, &workspace, edit);
|
||||
let result = json!({"docking": workspace});
|
||||
store(app, workspace, edit);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Existing Arrange commands reposition shared floating panels along with the legacy windows.
|
||||
pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> {
|
||||
let edit = app.ui.window == crate::MainWindow::Edit;
|
||||
let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
|
||||
if edit {
|
||||
workspace.validate()?;
|
||||
} else {
|
||||
workspace.validate_mix()?;
|
||||
}
|
||||
let previous = workspace.clone();
|
||||
let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect();
|
||||
for (index, (panel, mut rect)) in floating.into_iter().enumerate() {
|
||||
rect[0] = 32.0 + index as f32 * 28.0;
|
||||
rect[1] = 80.0 + index as f32 * 28.0;
|
||||
workspace.apply(Action::MoveFloating { panel, rect })?;
|
||||
}
|
||||
cancel_replaced_workspace(app, &previous, &workspace, edit);
|
||||
store(app, workspace, edit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) {
|
||||
app.docking_context = Some(ui.ctx().clone());
|
||||
let t = crate::theme::Tokens::current();
|
||||
let edit = window == crate::MainWindow::Edit;
|
||||
let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) };
|
||||
let before_sync = workspace.clone();
|
||||
if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) {
|
||||
app.ui.status = format!("Panel layout could not be restored: {error}");
|
||||
workspace = if edit { Workspace::default() } else { mix_default() };
|
||||
let _ = sync(&mut workspace, app, edit);
|
||||
}
|
||||
cancel_replaced_workspace(app, &before_sync, &workspace, edit);
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.min_pane = 96.0;
|
||||
style.tab_height = MIDI_TAB_HEIGHT;
|
||||
style.background = t.panel_bg;
|
||||
style.tab_background = t.panel_bg2;
|
||||
style.active_background = t.row_selected;
|
||||
style.text = t.text;
|
||||
style.inactive_text = t.text_dim;
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.font = crate::theme::regular(11.5);
|
||||
let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
|panel| panel.label().into(),
|
||||
permissions,
|
||||
panel_limits,
|
||||
|ui, panel| match panel {
|
||||
Panel::Canvas => {
|
||||
ui.painter().rect_filled(ui.max_rect(), 0.0, t.window_bg);
|
||||
if edit {
|
||||
crate::edit_window::main_area(app, ui);
|
||||
} else {
|
||||
crate::mix_window::content(app, ui);
|
||||
}
|
||||
}
|
||||
Panel::Tracks => crate::panels::tracks_and_groups(app, ui),
|
||||
Panel::Clips => crate::panels::clip_list(app, ui),
|
||||
Panel::Midi => {
|
||||
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect()));
|
||||
minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui));
|
||||
}
|
||||
Panel::Memory => crate::panels::memory_locations(app, ui),
|
||||
Panel::History => crate::panels::undo_history(app, ui),
|
||||
},
|
||||
);
|
||||
let _ = sync(&mut workspace, app, edit);
|
||||
publish_visibility(app, &workspace, edit);
|
||||
store(app, workspace, edit);
|
||||
for action in output.actions {
|
||||
if let Err(error) = serde_json::to_value(action)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|action| command_for(app, false, &json!({"action": action}), edit))
|
||||
{
|
||||
app.ui.status = format!("Panel move was not applied: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Docking may make a specialist view smaller than its fixed tool/ruler geometry. Keep that
|
||||
/// geometry scrollable inside its clipped panel instead of producing inverted rectangles.
|
||||
pub(crate) fn minimum_body(ui: &mut egui::Ui, minimum: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) {
|
||||
let available = ui.available_size();
|
||||
if available.x >= minimum.x && available.y >= minimum.y {
|
||||
body(ui);
|
||||
return;
|
||||
}
|
||||
let extent = available.max(minimum);
|
||||
egui::ScrollArea::both().id_salt("minimum-body").auto_shrink([false, false]).show(ui, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(extent, egui::Sense::hover());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
|
||||
body(&mut child);
|
||||
});
|
||||
}
|
||||
|
||||
fn panel_limits(panel: &Panel) -> PanelLimits {
|
||||
let (min, max_width) = match panel {
|
||||
Panel::Canvas => (egui::vec2(300.0, 240.0), 1_000_000.0),
|
||||
Panel::Tracks => (egui::vec2(140.0, 120.0), 520.0),
|
||||
Panel::Clips => (egui::vec2(180.0, 120.0), 720.0),
|
||||
Panel::Midi => (egui::vec2(420.0, 260.0), 1_000_000.0),
|
||||
Panel::Memory => (egui::vec2(260.0, 160.0), 1_000_000.0),
|
||||
Panel::History => (egui::vec2(200.0, 160.0), 720.0),
|
||||
};
|
||||
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::theme::{Tokens, bold, clip_colors, regular, rgb};
|
||||
use crate::widgets::{rec_toggle, selector_box, text_toggle};
|
||||
use crate::{Gesture, SoundApp, icons, panels, toolbar};
|
||||
use crate::{Gesture, SoundApp, icons, toolbar};
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind};
|
||||
@@ -66,27 +66,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui));
|
||||
egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui));
|
||||
egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui));
|
||||
if app.ui.show_midi_editor {
|
||||
egui::Panel::bottom("midi_editor")
|
||||
.resizable(true)
|
||||
.default_size(320.0)
|
||||
.min_size(260.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel_bg))
|
||||
.show(ui, |ui| crate::midi_editor::show(app, ui));
|
||||
}
|
||||
if app.ui.show_universe {
|
||||
egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Tokens::current().universe_bg)).show(ui, |ui| universe(app, ui));
|
||||
}
|
||||
if app.ui.show_tracks_list {
|
||||
egui::Panel::left("tracks_list")
|
||||
.exact_size(168.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel_bg))
|
||||
.show(ui, |ui| panels::tracks_and_groups(app, ui));
|
||||
}
|
||||
if app.ui.show_clip_list {
|
||||
egui::Panel::right("clip_list").exact_size(230.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| panels::clip_list(app, ui));
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui));
|
||||
crate::docking::show(app, ui, crate::MainWindow::Edit);
|
||||
}
|
||||
|
||||
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
|
||||
@@ -177,7 +160,17 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) {
|
||||
);
|
||||
}
|
||||
|
||||
fn main_area(app: &mut SoundApp, ui: &mut Ui) {
|
||||
pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let session = app.engine.session();
|
||||
let mut ruler_height_total = session.edit.rulers.iter().map(|ruler| ruler_height(ruler)).sum::<f32>();
|
||||
if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") {
|
||||
ruler_height_total += ruler_height("tempo_editor");
|
||||
}
|
||||
let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0);
|
||||
crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui));
|
||||
}
|
||||
|
||||
fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let full = ui.max_rect();
|
||||
let rulers: Vec<String> = app.engine.session().edit.rulers.clone();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod docking;
|
||||
pub mod edit_window;
|
||||
pub mod extra_windows;
|
||||
pub mod fonts;
|
||||
@@ -47,6 +48,9 @@ pub enum MainWindow {
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiState {
|
||||
pub docking: docking::Workspace,
|
||||
#[serde(default = "docking::mix_default")]
|
||||
pub mix_docking: docking::Workspace,
|
||||
pub window: MainWindow,
|
||||
pub show_tracks_list: bool,
|
||||
pub show_clip_list: bool,
|
||||
@@ -102,6 +106,8 @@ pub struct UiState {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
docking: docking::Workspace::default(),
|
||||
mix_docking: docking::mix_default(),
|
||||
window: MainWindow::Edit,
|
||||
show_tracks_list: true,
|
||||
show_clip_list: true,
|
||||
@@ -229,6 +235,8 @@ pub struct SoundApp {
|
||||
fonts_installed: bool,
|
||||
/// The palette last applied to the context (light?), so visuals are set only on a change.
|
||||
theme_applied: Option<bool>,
|
||||
/// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
|
||||
pub(crate) docking_context: Option<egui::Context>,
|
||||
/// Transport simulation when there is no player (tests, offscreen renders).
|
||||
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
|
||||
pub quit_requested: bool,
|
||||
@@ -286,6 +294,7 @@ impl SoundApp {
|
||||
fonts_ready: false,
|
||||
fonts_installed: false,
|
||||
theme_applied: None,
|
||||
docking_context: None,
|
||||
sim: None,
|
||||
quit_requested: false,
|
||||
arrange_request: false,
|
||||
|
||||
@@ -7,6 +7,8 @@ use soundcraft_engine::catalog;
|
||||
|
||||
/// UI-layer commands: (id, label, catalog path or "", shortcut).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
||||
("ui.dock", "Arrange Panel", "", None),
|
||||
("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None),
|
||||
("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
|
||||
("window.edit", "Edit", "Window > Edit", Some("Cmd+=")),
|
||||
("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),
|
||||
@@ -194,6 +196,7 @@ pub fn tree() -> Vec<MenuNode> {
|
||||
for line in catalog::catalog() {
|
||||
insert_path(&mut roots, line);
|
||||
}
|
||||
insert_path(&mut roots, "Window > Reset Panel Layout");
|
||||
for (path, _) in MENU_WINDOWS {
|
||||
insert_path(&mut roots, path);
|
||||
}
|
||||
@@ -277,7 +280,11 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
|
||||
/// Enabled and checked state shared by all menu presentations.
|
||||
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
|
||||
let enabled = match id {
|
||||
Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
|
||||
Some(i)
|
||||
if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>
|
||||
{
|
||||
true
|
||||
}
|
||||
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
|
||||
None => false,
|
||||
};
|
||||
@@ -427,6 +434,9 @@ fn wants_dialog(id: &str) -> bool {
|
||||
|
||||
/// Handle UI-layer commands. Returns None when `id` is not a UI command.
|
||||
pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "ui.dock" | "ui.dock.reset") {
|
||||
return Some(crate::docking::command(app, id == "ui.dock.reset", p));
|
||||
}
|
||||
if let Some(v) = crate::extra_windows::run(app, id, p) {
|
||||
return Some(Ok(v));
|
||||
}
|
||||
@@ -494,6 +504,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
}
|
||||
"window.configurations" => toggle(&mut app.ui.show_configurations),
|
||||
"window.config_new" | "window.config_update" => {
|
||||
if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) {
|
||||
return Some(Err(error));
|
||||
}
|
||||
let mut snap = app.ui.clone();
|
||||
snap.configurations.clear();
|
||||
let v = serde_json::to_value(&snap).unwrap_or_default();
|
||||
@@ -508,6 +521,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
json!({"configurations": app.ui.configurations.len()})
|
||||
}
|
||||
"window.arrange_tile" | "window.arrange_tile_h" | "window.arrange_tile_v" | "window.arrange_cascade" => {
|
||||
if let Err(error) = crate::docking::arrange(app) {
|
||||
app.ui.status = format!("Panel windows could not be arranged: {error}");
|
||||
}
|
||||
app.arrange_request = true;
|
||||
json!({})
|
||||
}
|
||||
@@ -567,8 +583,12 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
match serde_json::from_value(cur) {
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(ns) => {
|
||||
if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) {
|
||||
return Some(Err(error));
|
||||
}
|
||||
crate::docking::cancel_replaced_state(app, &ns);
|
||||
app.ui = ns;
|
||||
json!({"ui": app.ui})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! The Mix window: one channel strip per track.
|
||||
|
||||
use crate::SoundApp;
|
||||
use crate::theme::{Tokens, bold, regular, rgb};
|
||||
use crate::widgets::{
|
||||
PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, surround_panner, text_toggle,
|
||||
PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip,
|
||||
surround_panner, text_toggle,
|
||||
};
|
||||
use crate::{SoundApp, panels};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ChannelFormat, Route, SurroundPan, Track, TrackId, TrackKind};
|
||||
@@ -15,28 +16,23 @@ pub fn strip_width(narrow: bool) -> f32 {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
if app.ui.show_tracks_list {
|
||||
egui::Panel::left("mix_tracks_list")
|
||||
.exact_size(168.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel_bg))
|
||||
.show(ui, |ui| panels::tracks_and_groups(app, ui));
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| {
|
||||
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
|
||||
// Per-channel peaks for multichannel meters (read once per frame).
|
||||
let snap = app
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor))
|
||||
.map(|p| p.meters());
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 1.0;
|
||||
for id in ids {
|
||||
strip(app, ui, id, snap.as_ref());
|
||||
}
|
||||
});
|
||||
crate::docking::show(app, ui, crate::MainWindow::Mix);
|
||||
}
|
||||
|
||||
pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
|
||||
// Per-channel peaks for multichannel meters (read once per frame).
|
||||
let snap = app
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor))
|
||||
.map(|p| p.meters());
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 1.0;
|
||||
for id in ids {
|
||||
strip(app, ui, id, snap.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -247,7 +243,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
|
||||
} else {
|
||||
t.auto_write
|
||||
};
|
||||
let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col);
|
||||
let full_label = format!("auto {}", mode.label());
|
||||
let label = if mode == soundcraft_model::AutomationMode::TouchLatch {
|
||||
if narrow { "t/l" } else { mode.label() }
|
||||
} else if narrow {
|
||||
mode.label()
|
||||
} else {
|
||||
&full_label
|
||||
};
|
||||
let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for m in soundcraft_model::AutomationMode::ALL {
|
||||
if ui.selectable_label(m == mode, m.label()).clicked() {
|
||||
|
||||
@@ -168,9 +168,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
||||
/// Floating windows.
|
||||
pub fn floating(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
transport_window(app, ctx);
|
||||
memory_locations(app, ctx);
|
||||
big_counter(app, ctx);
|
||||
undo_history(app, ctx);
|
||||
plugin_windows(app, ctx);
|
||||
about(app, ctx);
|
||||
session_info(app, ctx);
|
||||
@@ -277,92 +275,78 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.ui.show_big_counter = open;
|
||||
}
|
||||
|
||||
fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_memory_locations;
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(480.0, 280.0)).show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("+ Marker").clicked() {
|
||||
let _ = app.run("markers.add", json!({}));
|
||||
pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("+ Marker").clicked() {
|
||||
let _ = app.run("markers.add", json!({}));
|
||||
}
|
||||
if ui.button("+ Selection").clicked() {
|
||||
let _ = app.run("markers.add", json!({"kind": "selection"}));
|
||||
}
|
||||
});
|
||||
let rows: Vec<(u32, String, String, String, String)> = {
|
||||
let s = app.engine.session();
|
||||
let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
s.markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let kind = match m.kind {
|
||||
soundcraft_model::MarkerKind::Marker => "marker",
|
||||
soundcraft_model::MarkerKind::Selection => "selection",
|
||||
soundcraft_model::MarkerKind::None => "none",
|
||||
};
|
||||
let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() };
|
||||
(m.number, m.name.clone(), f(m.start), len, kind.to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["#", "Name", "Location", "Length", "Type", ""] {
|
||||
ui.label(egui::RichText::new(h).strong());
|
||||
}
|
||||
if ui.button("+ Selection").clicked() {
|
||||
let _ = app.run("markers.add", json!({"kind": "selection"}));
|
||||
}
|
||||
});
|
||||
let rows: Vec<(u32, String, String, String, String)> = {
|
||||
let s = app.engine.session();
|
||||
let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
s.markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let kind = match m.kind {
|
||||
soundcraft_model::MarkerKind::Marker => "marker",
|
||||
soundcraft_model::MarkerKind::Selection => "selection",
|
||||
soundcraft_model::MarkerKind::None => "none",
|
||||
};
|
||||
let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() };
|
||||
(m.number, m.name.clone(), f(m.start), len, kind.to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["#", "Name", "Location", "Length", "Type", ""] {
|
||||
ui.label(egui::RichText::new(h).strong());
|
||||
ui.end_row();
|
||||
for (n, name, loc, len, kind) in rows {
|
||||
if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
|
||||
let _ = app.run("markers.recall", json!({"number": n}));
|
||||
}
|
||||
let key = egui::Id::new(("mem_name", n));
|
||||
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
|
||||
if r.changed() {
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
|
||||
}
|
||||
if r.lost_focus() && edit != name {
|
||||
let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
|
||||
ui.ctx().memory_mut(|m| m.data.remove::<String>(key));
|
||||
}
|
||||
ui.label(egui::RichText::new(loc).font(mono(11.0)));
|
||||
ui.label(egui::RichText::new(len).font(mono(11.0)));
|
||||
ui.label(kind);
|
||||
if ui.small_button("✕").on_hover_text("Delete").clicked() {
|
||||
let _ = app.run("markers.delete", json!({"number": n}));
|
||||
}
|
||||
ui.end_row();
|
||||
for (n, name, loc, len, kind) in rows {
|
||||
if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
|
||||
let _ = app.run("markers.recall", json!({"number": n}));
|
||||
}
|
||||
let key = egui::Id::new(("mem_name", n));
|
||||
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
|
||||
if r.changed() {
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
|
||||
}
|
||||
if r.lost_focus() && edit != name {
|
||||
let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
|
||||
ui.ctx().memory_mut(|m| m.data.remove::<String>(key));
|
||||
}
|
||||
ui.label(egui::RichText::new(loc).font(mono(11.0)));
|
||||
ui.label(egui::RichText::new(len).font(mono(11.0)));
|
||||
ui.label(kind);
|
||||
if ui.small_button("✕").on_hover_text("Delete").clicked() {
|
||||
let _ = app.run("markers.delete", json!({"number": n}));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
app.ui.show_memory_locations = open;
|
||||
}
|
||||
|
||||
fn undo_history(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_undo_history;
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| {
|
||||
let hist = app.engine.undo_history();
|
||||
let n = hist.len();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (i, h) in hist.iter().enumerate() {
|
||||
if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() {
|
||||
for _ in 0..(n - i - 1) {
|
||||
let _ = app.run("edit.undo", json!({}));
|
||||
}
|
||||
pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let hist = app.engine.undo_history();
|
||||
let n = hist.len();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (i, h) in hist.iter().enumerate() {
|
||||
if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() {
|
||||
for _ in 0..(n - i - 1) {
|
||||
let _ = app.run("edit.undo", json!({}));
|
||||
}
|
||||
}
|
||||
if let Some(r) = app.engine.redo_label() {
|
||||
ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(r) = app.engine.redo_label() {
|
||||
ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim));
|
||||
}
|
||||
});
|
||||
app.ui.show_undo_history = open;
|
||||
}
|
||||
|
||||
fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -6,6 +6,13 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
|
||||
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Unit tests that change the process-wide palette must own it until their assertions finish.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
|
||||
static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -443,7 +450,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn application_frames_follow_system_appearance_and_preserve_manual_palettes() {
|
||||
// Keep all frame assertions in one test: custom-painted tokens share LIGHT_THEME.
|
||||
// Mixer rendering tests also change the process-wide custom-painted palette.
|
||||
let _palette = lock_test_palette();
|
||||
fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option<egui::Theme>, light: bool) {
|
||||
let mode = app.ui.theme;
|
||||
let colors = |app: &SoundApp| {
|
||||
|
||||
+237
-27
@@ -1,24 +1,33 @@
|
||||
//! Small custom widgets in the studio style.
|
||||
|
||||
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::{Tokens, bold, regular};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
/// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar.
|
||||
fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle {
|
||||
let t = Tokens::current();
|
||||
let visuals = |fill, icon| ButtonVisuals { fill, stroke: Stroke::new(1.0, t.button_border), icon };
|
||||
IconButtonStyle {
|
||||
normal: visuals(t.button, normal_icon),
|
||||
hovered: visuals(t.button_hi, normal_icon),
|
||||
pressed: None,
|
||||
selected: visuals(selected_fill, Color32::WHITE),
|
||||
corner_radius: CornerRadius::same(radius),
|
||||
focus_stroke: Stroke::new(1.0, t.accent),
|
||||
}
|
||||
}
|
||||
|
||||
/// A toolbar button with an icon; `on` draws the blue selected state.
|
||||
pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response {
|
||||
let t = Tokens::current();
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let fill = if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.button_hi
|
||||
} else {
|
||||
t.button
|
||||
};
|
||||
ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||||
let col = if on { Color32::WHITE } else { t.text };
|
||||
let s = r.height().min(r.width()) * 0.78;
|
||||
icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col);
|
||||
let style = button_style(t.accent, t.text, 3);
|
||||
let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| {
|
||||
let s = r.height().min(r.width()) * 0.78;
|
||||
icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint);
|
||||
});
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
@@ -39,24 +48,22 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// Record-enable button (red dot).
|
||||
/// Record-enable button (red dot); the caller owns track arming and command dispatch.
|
||||
pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response {
|
||||
let t = Tokens::current();
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let fill = if on {
|
||||
t.rec
|
||||
} else if resp.hovered() {
|
||||
t.button_hi
|
||||
} else {
|
||||
t.button
|
||||
};
|
||||
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||||
ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) });
|
||||
let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2);
|
||||
let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| {
|
||||
ui.painter().circle_filled(r.center(), size.y * 0.24, tint);
|
||||
});
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// A dropdown-looking selector box. Returns the response (caller opens a menu on click).
|
||||
pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response {
|
||||
selector_box_with_tooltip(ui, width, height, text, color, text)
|
||||
}
|
||||
|
||||
/// A selector with a compact visible label and the complete meaning on hover.
|
||||
pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response {
|
||||
let t = Tokens::current();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
|
||||
ui.painter().rect(
|
||||
@@ -68,10 +75,15 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col
|
||||
);
|
||||
let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max);
|
||||
icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim);
|
||||
let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0));
|
||||
let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5);
|
||||
let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0));
|
||||
let mut job = egui::text::LayoutJob::simple(text.to_owned(), regular((height * 0.62).clamp(8.0, 12.0)), color, text_rect.width().max(1.0));
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.break_anywhere = true;
|
||||
job.wrap.overflow_character = Some('…');
|
||||
let galley = ui.painter().layout_job(job);
|
||||
let pos = text_rect.center() - galley.size() * 0.5;
|
||||
ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color);
|
||||
resp
|
||||
resp.on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets.
|
||||
@@ -371,3 +383,201 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod selector_visual_regressions {
|
||||
use super::*;
|
||||
|
||||
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
|
||||
let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_selector_visual_fixtures() {
|
||||
let _palette = crate::theme::lock_test_palette();
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut ready = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 120.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, regions: &mut serde_json::Map<String, serde_json::Value>| {
|
||||
if !ready {
|
||||
crate::fonts::install(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), false);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
regions.clear();
|
||||
for (index, label) in ["auto read", "no group", "A very long mixer group name"].into_iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
for width in [46.0, 74.0, 110.0] {
|
||||
let r = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
|
||||
regions.insert(format!("selector-{index}-{width}"), serde_json::json!([r.left(), r.top(), r.width(), r.height()]));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
serde_json::Map::new(),
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(4);
|
||||
let stem = format!("soundcraft-selectors-dark-{scale}x");
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
std::fs::write(
|
||||
directory.join(format!("{stem}.json")),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"screen": [320.0, 120.0], "scale": scale, "regions": harness.state()
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn mixer_app(narrow: bool, mode: soundcraft_model::AutomationMode) -> crate::SoundApp {
|
||||
let mut app = crate::SoundApp::new(soundcraft_engine::Engine::default(), None, Default::default());
|
||||
app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap();
|
||||
app.run("track.new", serde_json::json!({"name": "Music"})).unwrap();
|
||||
let ids: Vec<_> = app.engine.session().tracks.iter().map(|track| track.id).collect();
|
||||
for id in ids {
|
||||
app.run("mix.automation_mode", serde_json::json!({"track": id.0, "mode": mode.label()})).unwrap();
|
||||
}
|
||||
app.ui.narrow_mix = narrow;
|
||||
app.ui.show_tracks_list = false;
|
||||
app.ui.mix_views = vec!["io".into()];
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_mixer_keeps_every_active_automation_mode_legible() {
|
||||
let _palette = crate::theme::lock_test_palette();
|
||||
for narrow in [false, true] {
|
||||
for mode in soundcraft_model::AutomationMode::ALL {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx);
|
||||
crate::theme::apply(&ctx, false);
|
||||
let mut app = mixer_app(narrow, mode);
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(320.0, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let long = format!("auto {}", mode.label());
|
||||
let short = if mode == soundcraft_model::AutomationMode::TouchLatch { "t/l" } else { mode.label() };
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text)
|
||||
if text.galley.job.text == long || text.galley.job.text == mode.label() || text.galley.job.text == short =>
|
||||
{
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts.len(), 2, "both tracks paint their automation mode: {mode:?}");
|
||||
for text in texts {
|
||||
assert_eq!(text.galley.rows.len(), 1, "{mode:?} narrow={narrow} at {scale}x: automation text wraps");
|
||||
assert!(
|
||||
!text.galley.elided,
|
||||
"{mode:?} narrow={narrow} at {scale}x: the active mode must remain distinguishable without hovering"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_actual_mixer_visual_fixtures() {
|
||||
let _palette = crate::theme::lock_test_palette();
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for mode in soundcraft_model::AutomationMode::ALL {
|
||||
for narrow in [false, true] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut ready = false;
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(vec2(320.0, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut crate::SoundApp| {
|
||||
if !ready {
|
||||
crate::fonts::install(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), false);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::mix_window::show(app, ui);
|
||||
},
|
||||
mixer_app(narrow, mode),
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(4);
|
||||
let stem = format!("soundcraft-mixer-narrow-{narrow}-{}-{scale}x", mode.label().replace('/', "-"));
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let strips = harness
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Rect(rect)
|
||||
if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 =>
|
||||
{
|
||||
Some(rect.rect)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.fold(Rect::NOTHING, Rect::union);
|
||||
assert!(strips.is_finite());
|
||||
std::fs::write(
|
||||
directory.join(format!("{stem}.json")),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"screen": [320.0, 640.0], "scale": scale, "mode": mode.label(), "narrow": narrow,
|
||||
"regions": {"mixer-selectors": [strips.left(), strips.top(), strips.width(), 120.0]}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_mixer_selectors_keep_labels_on_one_visible_line() {
|
||||
let _palette = crate::theme::lock_test_palette();
|
||||
for width in [46.0, 74.0, 110.0] {
|
||||
for label in ["auto read", "no group", "A very long mixer group name"] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx);
|
||||
crate::theme::apply(&ctx, false);
|
||||
let mut rect = Rect::NOTHING;
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| {
|
||||
rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(320.0, 120.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let text = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.job.text == label => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.expect("selector label is painted");
|
||||
assert_eq!(text.galley.rows.len(), 1, "{label:?} at {width} must not wrap into clipped rows");
|
||||
let bounds = text.visual_bounding_rect();
|
||||
assert!(bounds.top() >= rect.top() && bounds.bottom() <= rect.bottom(), "{label:?}: {bounds:?} outside {rect:?}");
|
||||
assert!(
|
||||
bounds.left() >= rect.left() && bounds.right() <= rect.right() - 16.0 * 0.8,
|
||||
"text must leave room for the dropdown arrow"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +383,15 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if ui.button(format!("{} {name}", i + 1)).clicked()
|
||||
&& let Ok(mut st) = serde_json::from_value::<crate::UiState>(v.clone())
|
||||
{
|
||||
st.configurations = app.ui.configurations.clone();
|
||||
st.theme = app.ui.theme;
|
||||
st.show_configurations = true;
|
||||
app.ui = st;
|
||||
if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) {
|
||||
app.ui.status = format!("Window configuration could not be restored: {error}");
|
||||
} else {
|
||||
st.configurations = app.ui.configurations.clone();
|
||||
st.theme = app.ui.theme;
|
||||
st.show_configurations = true;
|
||||
crate::docking::cancel_replaced_state(app, &st);
|
||||
app.ui = st;
|
||||
}
|
||||
}
|
||||
if ui.small_button("✕").clicked() {
|
||||
app.ui.configurations.remove(i);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Real Edit/Mix panel workflows, persistence, and command ownership without OS windows.
|
||||
use egui::{Event, Modifiers, PointerButton, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState, docking::Panel};
|
||||
|
||||
fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("track.new", json!({"name":"Voice"})).unwrap();
|
||||
if let Some(state) = state {
|
||||
app.ui = state;
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(2);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
for step in 1..=8 {
|
||||
h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").rect().center();
|
||||
let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center();
|
||||
drag(&mut h, source, target);
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks));
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Float panel").click();
|
||||
h.run_steps(5);
|
||||
let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect;
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Close panel").click();
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.show_clip_list);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect);
|
||||
let saved = serde_json::to_string(&h.state().ui).unwrap();
|
||||
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
|
||||
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
|
||||
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
|
||||
reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap();
|
||||
reloaded.run_steps(4);
|
||||
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click();
|
||||
reloaded.run_steps(3);
|
||||
reloaded.get_by_label("Trim (F6)").click();
|
||||
reloaded.run_steps(3);
|
||||
assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim);
|
||||
assert!(!reloaded.state().engine.transport.playing);
|
||||
let edit = serde_json::to_value(&reloaded.state().ui.docking).unwrap();
|
||||
reloaded.state_mut().ui.window = MainWindow::Mix;
|
||||
reloaded.run_steps(5);
|
||||
reloaded.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[900,70,220,420]})).unwrap();
|
||||
reloaded.run_steps(4);
|
||||
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit);
|
||||
assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips));
|
||||
let record = reloaded.get_by_label("Record enable");
|
||||
// A real pointer click must remain outside the floating panel's occluding rectangle.
|
||||
assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center()));
|
||||
record.click();
|
||||
reloaded.run_steps(3);
|
||||
assert!(reloaded.state().engine.session().tracks[0].mixer.record_arm);
|
||||
assert!(!reloaded.state().engine.transport.recording);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_docking_requests_are_atomic_in_edit_and_mix() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
for window in [MainWindow::Edit, MainWindow::Mix] {
|
||||
h.state_mut().ui.window = window;
|
||||
h.run_steps(4);
|
||||
let before = serde_json::to_value(&h.state().ui).unwrap();
|
||||
for params in [
|
||||
json!({"operation":"close","panel":"canvas"}),
|
||||
json!({"operation":"move","panel":"tracks","target":"canvas","zone":"center"}),
|
||||
json!({"operation":"float","panel":"tracks","rect":[0,0,-1,100]}),
|
||||
] {
|
||||
assert!(h.state_mut().run("ui.dock", params).is_err());
|
||||
assert_eq!(serde_json::to_value(&h.state().ui).unwrap(), before);
|
||||
}
|
||||
if window == MainWindow::Mix {
|
||||
assert!(h.state_mut().run("ui.dock", json!({"operation":"open","panel":"midi"})).is_err());
|
||||
}
|
||||
assert_eq!(h.state().engine.session().tracks.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docking_renders_edit_and_mix_default_floating_and_redocked() {
|
||||
for width in [1000.0, 1400.0] {
|
||||
for scale in [1.0, 2.0] {
|
||||
for window in [MainWindow::Edit, MainWindow::Mix] {
|
||||
let mut h = harness(Some(UiState { window, ..Default::default() }), width, scale);
|
||||
h.state_mut().engine = soundcraft_engine::demo::demo_engine();
|
||||
for state in ["default", "floating", "redocked"] {
|
||||
if state == "floating" {
|
||||
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[100,80,260,500]})).unwrap();
|
||||
} else if state == "redocked" {
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap();
|
||||
}
|
||||
h.run_steps(5);
|
||||
h.state().ui.docking.validate().unwrap();
|
||||
h.state().ui.mix_docking.validate().unwrap();
|
||||
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() {
|
||||
use soundcraft_ui_egui::menus;
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let path = "Window > Reset Panel Layout";
|
||||
let extra: Vec<_> = menus::UI_COMMANDS.iter().map(|(id, _, path, _)| (*path, *id)).collect();
|
||||
assert_eq!(menus::command_for_path(path, &extra).as_deref(), Some("ui.dock.reset"));
|
||||
let tree = menus::tree();
|
||||
let window = tree.iter().find(|node| node.label == "Window").unwrap();
|
||||
assert!(window.children.iter().any(|node| node.path == path));
|
||||
assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0);
|
||||
app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap();
|
||||
menus::invoke_menu(&mut app, "ui.dock.reset", path);
|
||||
assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_preferences_restore_the_theme_and_validate_default_docking() {
|
||||
let mut value = serde_json::to_value(UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, ..Default::default() }).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("docking");
|
||||
object.remove("mix_docking");
|
||||
let restored: UiState = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
restored.docking.validate().unwrap();
|
||||
restored.mix_docking.validate_mix().unwrap();
|
||||
}
|
||||
@@ -13,7 +13,7 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
|
||||
}
|
||||
|
||||
fn panel(ctx: &egui::Context) -> Rect {
|
||||
egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect
|
||||
ctx.data(|data| data.get_temp::<Rect>(Id::new("soundcraft-midi-body"))).unwrap()
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
@@ -39,8 +39,8 @@ fn check_resize(with_clip: bool) {
|
||||
// to catch a panel that looks resized but snaps back after release.
|
||||
for height in [440.0, 280.0, 200.0] {
|
||||
let before = panel(&ctx);
|
||||
let start = pos2(before.center().x, before.top() + 1.0);
|
||||
let end = pos2(start.x, before.bottom() - height);
|
||||
let start = pos2(before.center().x, before.top() - 26.0 - 2.0);
|
||||
let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
|
||||
frame(&ctx, &mut app, vec![button(start, true)]);
|
||||
for step in 1..=6 {
|
||||
@@ -65,6 +65,12 @@ fn check_resize(with_clip: bool) {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert_eq!(panel(&ctx), resized);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
app.ui = serde_json::from_str(&saved).unwrap();
|
||||
for _ in 0..4 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert_eq!(panel(&ctx), resized);
|
||||
assert_eq!(*app.engine.session(), notes);
|
||||
}
|
||||
|
||||
@@ -77,3 +83,70 @@ fn midi_panel_resizes_with_a_selected_clip() {
|
||||
fn midi_panel_resizes_without_a_selected_clip() {
|
||||
check_resize(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() {
|
||||
check_held_replacement("reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_cancels_a_held_midi_divider_without_editing_the_session() {
|
||||
check_held_replacement("arrange");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() {
|
||||
check_held_replacement("visibility");
|
||||
}
|
||||
|
||||
fn check_held_replacement(operation: &str) {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
app.run("edit.select", json!({"tracks":["Keys"]})).unwrap();
|
||||
app.ui.show_memory_locations = operation == "arrange";
|
||||
let session = app.engine.session().clone();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let initial = panel(&ctx);
|
||||
let start = pos2(initial.center().x, initial.top() - 28.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
|
||||
frame(&ctx, &mut app, vec![button(start, true)]);
|
||||
let first = start - vec2(0.0, 80.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(first)]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize");
|
||||
let second = start - vec2(0.0, 120.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(second)]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing");
|
||||
match operation {
|
||||
"reset" => {
|
||||
app.run("ui.dock.reset", json!({})).unwrap();
|
||||
}
|
||||
"arrange" => {
|
||||
app.run("window.arrange_cascade", json!({})).unwrap();
|
||||
}
|
||||
"visibility" => {
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
_ => panic!("unknown synthetic operation"),
|
||||
}
|
||||
let reset = serde_json::to_value(&app.ui.docking).unwrap();
|
||||
let after_reset = start - vec2(0.0, 160.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout");
|
||||
let expected = if operation == "reset" { 320.0 } else { 440.0 };
|
||||
assert!((panel(&ctx).height() - expected).abs() < 1.0);
|
||||
frame(&ctx, &mut app, vec![button(after_reset, false)]);
|
||||
for _ in 0..4 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset);
|
||||
assert_eq!(*app.engine.session(), session);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Interaction and optional offscreen evidence for the application's painted controls.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, theme, widgets};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ButtonState {
|
||||
record: bool,
|
||||
selected: bool,
|
||||
disabled: bool,
|
||||
clicks: usize,
|
||||
response: Option<Response>,
|
||||
}
|
||||
|
||||
impl ButtonState {
|
||||
fn label(&self) -> &'static str {
|
||||
if self.record { "Record enable" } else { "Loop playback" }
|
||||
}
|
||||
}
|
||||
|
||||
fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec<Event>) -> egui::FullOutput {
|
||||
let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
let response = ui
|
||||
.add_enabled_ui(!state.disabled, |ui| {
|
||||
if state.record {
|
||||
widgets::rec_toggle(ui, vec2(38.0, 28.0), state.selected, state.label())
|
||||
} else {
|
||||
widgets::icon_button(ui, vec2(38.0, 28.0), "loop", state.selected, state.label())
|
||||
}
|
||||
})
|
||||
.inner;
|
||||
state.clicks += usize::from(response.clicked());
|
||||
state.response = Some(response);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn pointer(pos: egui::Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_and_record_buttons_preserve_distinct_selected_colors_and_caller_state() {
|
||||
for record in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut state = ButtonState { record, selected: true, ..Default::default() };
|
||||
button_frame(&ctx, &mut state, vec![]);
|
||||
let center = state.response.as_ref().unwrap().rect.center();
|
||||
let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]);
|
||||
let expected = if record { theme::Tokens::current().rec } else { theme::Tokens::current().accent };
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == expected)));
|
||||
if record {
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == egui::Color32::WHITE)));
|
||||
}
|
||||
let update = out.platform_output.accesskit_update.unwrap();
|
||||
let node = &update.nodes.iter().find(|(_, n)| n.label() == Some(state.label())).unwrap().1;
|
||||
assert_eq!(node.role(), egui::accesskit::Role::Button);
|
||||
assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True));
|
||||
button_frame(&ctx, &mut state, vec![pointer(center, false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
assert!(state.selected, "selection and record arming remain caller-owned");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_and_record_buttons_handle_keyboard_disabled_state_and_cancelled_clicks() {
|
||||
for record in [false, true] {
|
||||
for key_code in [Key::Enter, Key::Space] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut state = ButtonState { record, ..Default::default() };
|
||||
button_frame(&ctx, &mut state, vec![]);
|
||||
button_frame(&ctx, &mut state, vec![]);
|
||||
let response = state.response.clone().unwrap();
|
||||
ctx.memory_mut(|m| m.request_focus(response.id));
|
||||
let out = button_frame(&ctx, &mut state, vec![]);
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r)
|
||||
if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::current().accent)));
|
||||
button_frame(&ctx, &mut state, vec![key(key_code, true)]);
|
||||
button_frame(&ctx, &mut state, vec![key(key_code, false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
let center = response.rect.center();
|
||||
button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]);
|
||||
button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]);
|
||||
button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
state.disabled = true;
|
||||
button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]);
|
||||
let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
assert!(!state.response.as_ref().unwrap().enabled());
|
||||
assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some(state.label()) && n.is_disabled()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions() {
|
||||
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h.get_by_label("Trim (F6)").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().engine.session().edit.tool, soundcraft_model::Tool::Trim);
|
||||
let markers = h.state().engine.session().markers.len();
|
||||
for (label, tool, key) in
|
||||
[("Pencil (F10)", soundcraft_model::Tool::Pencil, Key::Enter), ("Grabber (F8)", soundcraft_model::Tool::Grabber, Key::Space)]
|
||||
{
|
||||
h.get_by_label(label).focus();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label(label).is_focused());
|
||||
h.key_press(key);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().engine.session().edit.tool, tool);
|
||||
assert!(!h.state().engine.transport.playing);
|
||||
assert!(!h.state().engine.transport.recording);
|
||||
assert_eq!(h.state().engine.session().markers.len(), markers);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_record_button_arms_the_track_without_starting_transport() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h.get_by_label("Record enable").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().engine.session().tracks[0].mixer.record_arm);
|
||||
for (key, armed) in [(Key::Enter, false), (Key::Space, true)] {
|
||||
h.get_by_label("Record enable").focus();
|
||||
h.run_steps(2);
|
||||
h.key_press(key);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().engine.session().tracks[0].mixer.record_arm, armed);
|
||||
assert!(!h.state().engine.transport.playing);
|
||||
assert!(!h.state().engine.transport.recording);
|
||||
assert!(h.state().engine.session().markers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_controls_visuals() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for scale in [1.0, 2.0] {
|
||||
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.input_mut().max_texture_side = Some(8192);
|
||||
for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(1000.0, 700.0))] {
|
||||
h.set_size(size);
|
||||
h.run_steps(6);
|
||||
h.render().unwrap().save(directory.join(format!("soundcraft-{name}-dark-{scale}x.png"))).unwrap();
|
||||
}
|
||||
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|ui, rects: &mut Vec<egui::Rect>| {
|
||||
theme::apply(ui.ctx(), false);
|
||||
rects.clear();
|
||||
for enabled in [true, false] {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for selected in [false, true] {
|
||||
rects.push(widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop Playback").rect);
|
||||
rects.push(widgets::icon_button(ui, vec2(36.0, 26.0), "trim", selected, "Trim").rect);
|
||||
rects.push(widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable").rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
controls.run_steps(4);
|
||||
controls.render().unwrap().save(directory.join(format!("soundcraft-controls-dark-{scale}x.png"))).unwrap();
|
||||
for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] {
|
||||
let center = controls.state()[index].center();
|
||||
controls.event(Event::PointerMoved(center));
|
||||
if pressed {
|
||||
controls.event(pointer(center, true));
|
||||
}
|
||||
controls.run_steps(2);
|
||||
controls.render().unwrap().save(directory.join(format!("soundcraft-controls-{name}-dark-{scale}x.png"))).unwrap();
|
||||
}
|
||||
controls.event(pointer(controls.state()[0].center(), false));
|
||||
controls.event(Event::PointerGone);
|
||||
controls.run_steps(2);
|
||||
controls.key_press(Key::Tab);
|
||||
controls.run_steps(2);
|
||||
controls.render().unwrap().save(directory.join(format!("soundcraft-controls-focus-dark-{scale}x.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Synthetic appearance checks for populated shared panels and custom controls.
|
||||
use egui::{Color32, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, theme, widgets};
|
||||
|
||||
#[test]
|
||||
fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
|
||||
// Keep palette changes in one owning test: upstream custom painters use process-global tokens.
|
||||
for scale in [1.0, 2.0] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("edit.select", json!({"tracks":["Keys"]})).unwrap();
|
||||
for id in ["window.midi_editor", "window.memory_locations", "window.undo_history"] {
|
||||
app.run(id, json!({})).unwrap();
|
||||
}
|
||||
let session = app.engine.session().clone();
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.input_mut().max_texture_side = Some(8192);
|
||||
for (name, mode, system, light) in [
|
||||
("dark", theme::ThemeMode::Dark, egui::Theme::Light, false),
|
||||
("light", theme::ThemeMode::Light, egui::Theme::Dark, true),
|
||||
("system-light", theme::ThemeMode::System, egui::Theme::Light, true),
|
||||
("system-dark", theme::ThemeMode::System, egui::Theme::Dark, false),
|
||||
] {
|
||||
h.state_mut().ui.theme = mode;
|
||||
h.input_mut().system_theme = Some(system);
|
||||
for (width_name, size) in [("normal", vec2(1400.0, 1000.0)), ("narrow", vec2(1000.0, 800.0))] {
|
||||
h.set_size(size);
|
||||
h.run_steps(5);
|
||||
assert_eq!(theme::is_light(), light);
|
||||
assert_eq!(h.state().ui.theme, mode);
|
||||
assert_eq!(*h.state().engine.session(), session);
|
||||
h.state().ui.docking.validate().unwrap();
|
||||
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
theme::apply(&ctx, light);
|
||||
let tokens = theme::Tokens::current();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback");
|
||||
widgets::rec_toggle(ui, vec2(30.0, 18.0), true, "Record enable");
|
||||
ui.add_enabled_ui(false, |ui| widgets::icon_button(ui, vec2(38.0, 28.0), "loop", false, "Disabled loop"));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
for expected in [tokens.accent, tokens.rec] {
|
||||
assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == expected)));
|
||||
}
|
||||
assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Circle(circle) if circle.fill == Color32::WHITE)));
|
||||
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
|
||||
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, _: &mut ()| {
|
||||
theme::apply(ui.ctx(), light);
|
||||
for enabled in [true, false] {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for selected in [false, true] {
|
||||
widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop playback");
|
||||
widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
(),
|
||||
);
|
||||
controls.run_steps(4);
|
||||
controls.render().unwrap().save(directory.join(format!("sound-controls-{name}-{scale}x.png"))).unwrap();
|
||||
controls.key_press(egui::Key::Tab);
|
||||
controls.run_steps(2);
|
||||
controls.render().unwrap().save(directory.join(format!("sound-controls-focus-{name}-{scale}x.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,13 @@
|
||||
SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`).
|
||||
Nothing below the UI knows about egui, so the interface can be replaced.
|
||||
|
||||
The UI package pins `craft-ui` for common icon-button and track record-enable interactions.
|
||||
SoundCraft supplies each state's colors, border, radius and icon painter; selection and command
|
||||
dispatch remain in the toolbar and track controls. The single-shape frame option preserves the
|
||||
existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling,
|
||||
nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI
|
||||
requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration.
|
||||
|
||||
```
|
||||
L0 time audio-io midi video (standalone: no workspace deps)
|
||||
L1 dsp clap-host vst3-host au-host (plugins; the three hosts are the only crates
|
||||
|
||||
@@ -12,6 +12,18 @@ input), and every command is reachable from:
|
||||
| MCP (headless) | `soundcraft-cli mcp [--demo]` |
|
||||
| MCP (bridged to the app) | `soundcraft-cli mcp --connect <port>` |
|
||||
|
||||
## Isolated automation profiles
|
||||
|
||||
Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`.
|
||||
The nonempty value is used as the complete settings directory. Unset retains the normal platform paths;
|
||||
an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS`
|
||||
when testing preference persistence across restarts.
|
||||
|
||||
Run UI acceptance with `--no-audio`. This disables the output player; it does not disable recording
|
||||
input opened by arm/monitor commands. An automated UI smoke run must never arm a track, enable input
|
||||
monitoring, or start recording. Control input injects egui events; it does not establish operating-system
|
||||
input delivery or screen-reader acceptance. Native screenshots use the presented viewport frame.
|
||||
|
||||
## Wire format
|
||||
|
||||
Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:
|
||||
@@ -72,3 +84,22 @@ the native or web integration at launch and on later frames, while the saved cho
|
||||
When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay
|
||||
fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no
|
||||
OS appearance and therefore resolves System to Dark.
|
||||
|
||||
## Panel docking
|
||||
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
|
||||
|
||||
Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`.
|
||||
|
||||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
|
||||
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`.
|
||||
|
||||
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
|
||||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||||
rules apply equally to both forms.
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ const LAYERS: &[(&str, u8)] = &[
|
||||
];
|
||||
/// Standalone format crates: no workspace dependencies at all (publishable on their own).
|
||||
const STANDALONE: &[&str] = &["time", "audio-io", "midi", "video", "dsp"];
|
||||
const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"];
|
||||
const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"];
|
||||
const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"];
|
||||
|
||||
fn layers() -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user