Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 e60c41c2c8 Release workflow: pass the Azure signing secrets so Windows builds are signed
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:21 -04:00
Dylan M. Taylor 240aa848a0 ci: refactor workflows to use storytold/craft-common-actions (#184)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:52:00 -04:00
skylar taylor-barrick 7414719f37 Merge pull request #167 from skylartaylor/codex/ui-suite-integration-20261010
Add shared docking with SoundCraft theme and MIDI sizing
2026-10-10 19:47:00 -06:00
skylar taylor-barrick 814ae99a32 UI shared controls: isolate palette ownership in rendering tests 2026-10-10 19:27:18 -06:00
skylar taylor-barrick 0434bb1397 UI.9: recover incompatible saved panel layouts independently 2026-10-10 18:52:14 -06:00
skylar taylor-barrick 7c64b27753 UI.9: reconcile upstream before shared UI landing 2026-10-10 18:24:17 -06:00
skylar taylor-barrick 5589d97257 UI.9: reconcile upstream before shared UI landing 2026-10-10 18:14:05 -06:00
skylar taylor-barrick 63b487589c UI.9: preserve SoundCraft workflows with shared docking 2026-10-10 15:37:36 -06:00
29 changed files with 1751 additions and 752 deletions
+2 -24
View File
@@ -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
+2 -46
View File
@@ -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
View File
@@ -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 }}
+6 -66
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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"
+4 -1
View File
@@ -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.
+1
View File
@@ -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,
+1 -1
View File
@@ -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]") };
+244
View File
@@ -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
View File
@@ -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)");
}
}
}
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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
+475
View File
@@ -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) }
}
+13 -20
View File
@@ -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();
+9
View File
@@ -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,
+22 -2
View File
@@ -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})
}
+29 -25
View File
@@ -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() {
+62 -78
View File
@@ -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) {
+9 -1
View File
@@ -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
View File
@@ -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"
);
}
}
}
}
}
+9 -4
View File
@@ -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);
+159
View File
@@ -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();
}
+76 -3
View File
@@ -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);
}
+217
View File
@@ -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();
}
}
+83
View File
@@ -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();
}
}
}
}
+7
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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> {