Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e151ab4f26 | ||
|
|
e28f61447f | ||
|
|
388552f1c4 | ||
|
|
e68e73d3d5 | ||
|
|
a5669805c8 | ||
|
|
cef7336517 | ||
|
|
50a3c1a1e5 | ||
|
|
684f5ce866 | ||
|
|
80da40f678 | ||
|
|
3cca2f41f4 | ||
|
|
6511d9d007 | ||
|
|
d5a2afdea5 | ||
|
|
24830172ae | ||
|
|
fd7dd89f55 | ||
|
|
dac6c729cd | ||
|
|
d48155683b | ||
|
|
e1d6398d57 | ||
|
|
4aae47cd27 | ||
|
|
61f821c3e9 | ||
|
|
b74857d7f6 | ||
|
|
d3a783f77f | ||
|
|
f965779373 | ||
|
|
b0d4e372cb | ||
|
|
e893cab082 | ||
|
|
10b890b5ae | ||
|
|
e05d589311 | ||
|
|
00b6721c42 | ||
|
|
4f2bce1f5f | ||
|
|
8c0b272ab9 | ||
|
|
772a331f04 | ||
|
|
1643a887af | ||
|
|
8e9a59d026 | ||
|
|
1792910ca1 | ||
|
|
faa019d52b | ||
|
|
f6bc46dd98 | ||
|
|
30e1c0b0a9 | ||
|
|
9f2ace9698 | ||
|
|
1e00ca3a7c | ||
|
|
2717dfe99a | ||
|
|
fd545fb2a8 | ||
|
|
3793ee9f5b | ||
|
|
014ccf5197 | ||
|
|
0f198692ad | ||
|
|
19c3aedf80 | ||
|
|
9589f6f3f0 | ||
|
|
037d17ff3d | ||
|
|
20182a8e36 | ||
|
|
8f5a7c22cc | ||
|
|
ac23f98fcd | ||
|
|
81a99b7173 | ||
|
|
75ac349790 | ||
|
|
21728b5e6d | ||
|
|
d6d0215e09 | ||
|
|
a0c9fbb674 | ||
|
|
a28dd890dd | ||
|
|
00adc4df53 | ||
|
|
eb751f9404 | ||
|
|
5945b48b98 | ||
|
|
ab9de39556 | ||
|
|
ddec10ca8f | ||
|
|
34fad6f905 | ||
|
|
d91bcaa20f | ||
|
|
b572e1c794 | ||
|
|
0f66ed72f4 | ||
|
|
ee3f56fcdf | ||
|
|
fbcaaf31aa | ||
|
|
470b95ace3 | ||
|
|
e802067056 | ||
|
|
56f7aca174 | ||
|
|
78ea04453e | ||
|
|
e110a28f80 | ||
|
|
daf2bf32aa |
@@ -1,24 +1,24 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"tabWidth": 2
|
"tabWidth": 2
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
# Tauri + React + Typescript
|
# Tauri + React + Typescript
|
||||||
|
|
||||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Recommended IDE Setup
|
||||||
|
|
||||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
|
||||||
|
|
||||||
|
- Check render to improve perfoormances
|
||||||
|
|
||||||
|
- Check in meter if clip bool is limit instead
|
||||||
|
|
||||||
|
- On close disconenct all ds^p
|
||||||
|
|
||||||
|
- Check end to end behavioour
|
||||||
|
|
||||||
|
- Roll back on scroll when 2 scroll
|
||||||
|
|
||||||
|
- test en dto end connection
|
||||||
|
|
||||||
|
Data: 10020001052f 31313131 10032a // lock
|
||||||
|
|
||||||
|
Data: 10020001052d 31313131 100328 // unlock fail
|
||||||
|
Data: 10020100032d 3100 10031f
|
||||||
|
|
||||||
|
Data: 10020001062d 0031313131 10032b // unlock success
|
||||||
|
Data: 10020100032d 0001 10032f
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<title>Tauri + React + Typescript</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "thomann-dsp-app",
|
"name": "thomann-dsp-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
# Generated by Tauri
|
# Generated by Tauri
|
||||||
# will have schema files for capabilities auto-completion
|
# will have schema files for capabilities auto-completion
|
||||||
/gen/schemas
|
/gen/schemas
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -225,6 +225,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -397,7 +403,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -453,6 +459,17 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -543,6 +560,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -618,9 +644,9 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"darling_macro",
|
"darling_macro",
|
||||||
@@ -628,26 +654,26 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_core"
|
name = "darling_core"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -661,6 +687,37 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"defmt-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-macros"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||||
|
dependencies = [
|
||||||
|
"defmt-parser",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -796,11 +853,13 @@ dependencies = [
|
|||||||
name = "dsp_thomann"
|
name = "dsp_thomann"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_with",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1254,6 +1313,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1761,6 +1821,60 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
"jiff-core",
|
||||||
|
"jiff-static",
|
||||||
|
"jiff-tzdb-platform",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-core"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-core",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-tzdb"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-tzdb-platform"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-tzdb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2004,7 +2118,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2492,6 +2606,21 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2605,6 +2734,23 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2628,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2648,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2882,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2917,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2940,16 +3086,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
|
"jiff",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -2960,14 +3107,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.21.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3008,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3198,9 +3345,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.2"
|
version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3299,9 +3446,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.11.5"
|
version = "2.11.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
|
checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3338,7 +3485,7 @@ dependencies = [
|
|||||||
"tauri-runtime",
|
"tauri-runtime",
|
||||||
"tauri-runtime-wry",
|
"tauri-runtime-wry",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"url",
|
"url",
|
||||||
@@ -3389,7 +3536,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3428,9 +3575,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -3442,7 +3589,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -3466,7 +3613,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
@@ -3529,7 +3676,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
@@ -3581,11 +3728,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.19",
|
"thiserror-impl 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3601,13 +3748,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.19"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3918,7 +4065,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4278,7 +4425,7 @@ version = "0.38.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -4713,7 +4860,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"soup3",
|
"soup3",
|
||||||
"tao-macros",
|
"tao-macros",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.21",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2.11.6", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2.5.5"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
dsp_thomann = { path = "../../dsp_thomann", features = ["serialization"] }
|
dsp_thomann = { path = "../../dsp-thomann", features = ["serialization"] }
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default"
|
"opener:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="richGradient"
|
||||||
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="100%"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stop-color="#1e3a8a" stop-opacity="1"/>
|
||||||
|
<stop offset="25%" stop-color="#2d3485"/>
|
||||||
|
<stop offset="45%" stop-color="#4c2e7f"/>
|
||||||
|
<stop offset="60%" stop-color="#6b2878"/>
|
||||||
|
<stop offset="80%" stop-color="#b9326e"/>
|
||||||
|
<stop offset="100%" stop-color="#ea437a" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<radialGradient
|
||||||
|
id="darkenGradient"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
r="50%"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3"/>
|
||||||
|
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.1"/>
|
||||||
|
<stop offset="60%" stop-color="#000000" stop-opacity="0"/>
|
||||||
|
<stop offset="80%" stop-color="#000000" stop-opacity="0.2"/>
|
||||||
|
<stop offset="100%" stop-color="#000000" stop-opacity="0.5"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient
|
||||||
|
id="shadowGradient"
|
||||||
|
x1="20%"
|
||||||
|
y1="20%"
|
||||||
|
x2="80%"
|
||||||
|
y2="80%"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stop-color="#0f1d42"/>
|
||||||
|
<stop offset="25%" stop-color="#1f2a5d"/>
|
||||||
|
<stop offset="45%" stop-color="#332f78"/>
|
||||||
|
<stop offset="60%" stop-color="#5f3458"/>
|
||||||
|
<stop offset="80%" stop-color="#7a2250"/>
|
||||||
|
<stop offset="100%" stop-color="#a50d57"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Main donut -->
|
||||||
|
<path
|
||||||
|
d="
|
||||||
|
M100,20
|
||||||
|
A80,80 0 1,1 99.9,20
|
||||||
|
M100,60
|
||||||
|
A40,40 0 1,0 100.1,60
|
||||||
|
"
|
||||||
|
fill="url(#richGradient)"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Darkening / depth overlay -->
|
||||||
|
<path
|
||||||
|
d="
|
||||||
|
M100,20
|
||||||
|
A80,80 0 1,1 99.9,20
|
||||||
|
M100,60
|
||||||
|
A40,40 0 1,0 100.1,60
|
||||||
|
"
|
||||||
|
fill="url(#darkenGradient)"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Inner shadow -->
|
||||||
|
<path
|
||||||
|
d="
|
||||||
|
M100,60
|
||||||
|
A40,40 0 1,0 100.1,60
|
||||||
|
M100,75
|
||||||
|
A25,25 0 1,1 99.9,75
|
||||||
|
"
|
||||||
|
fill="url(#shadowGradient)"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,23 +1,17 @@
|
|||||||
use tauri::State;
|
|
||||||
use crate::state::{AppState, DSPDevice};
|
use crate::state::{AppState, DSPDevice};
|
||||||
use dsp_thomann::dsp408::DSP408;
|
use dsp_thomann::dsp408::types::DelayUnit;
|
||||||
use std::{net::IpAddr, str::FromStr};
|
use dsp_thomann::dsp408::{types, DSP408};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::{net::IpAddr, str::FromStr};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn with_device_mut<F, R>(
|
pub fn with_device_mut<F, R>(&self, id: u64, f: F) -> Result<R, String>
|
||||||
&self,
|
|
||||||
id: u64,
|
|
||||||
f: F,
|
|
||||||
) -> Result<R, String>
|
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut DSPDevice) -> Result<R, String>,
|
F: FnOnce(&mut DSPDevice) -> Result<R, String>,
|
||||||
{
|
{
|
||||||
let device = {
|
let device = {
|
||||||
let devices = self
|
let devices = self.devices.lock().map_err(|e| e.to_string())?;
|
||||||
.devices
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
devices
|
devices
|
||||||
.get(&id)
|
.get(&id)
|
||||||
@@ -25,22 +19,14 @@ impl AppState {
|
|||||||
.ok_or_else(|| "DSP not found".to_string())?
|
.ok_or_else(|| "DSP not found".to_string())?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut device = device
|
let mut device = device.lock().map_err(|e| e.to_string())?;
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
f(&mut device)
|
f(&mut device)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_device(
|
pub fn remove_device(&self, id: u64) -> Result<(), String> {
|
||||||
&self,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
|
|
||||||
let device = {
|
let device = {
|
||||||
let mut devices = self.devices
|
let mut devices = self.devices.lock().map_err(|e| e.to_string())?;
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
devices
|
devices
|
||||||
.remove(&id)
|
.remove(&id)
|
||||||
@@ -55,57 +41,48 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct DSP408Snapshot {
|
||||||
|
pub state: types::DSPState,
|
||||||
|
pub input_meters: types::InputMeters,
|
||||||
|
pub output_meters: types::OutputMeters,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_dsp408(
|
pub fn create_dsp408(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
ip: String,
|
ip: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
device_id: u8,
|
device_id: u8,
|
||||||
|
demo: bool,
|
||||||
) -> Result<u64, String> {
|
) -> Result<u64, String> {
|
||||||
|
let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let ip = IpAddr::from_str(&ip)
|
let dsp = if demo {
|
||||||
.map_err(|e| e.to_string())?;
|
DSP408::new_demo()
|
||||||
|
} else {
|
||||||
|
DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
let dsp = DSP408::new(ip, port, device_id)
|
let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut next_id = state
|
|
||||||
.next_id
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let id = *next_id;
|
let id = *next_id;
|
||||||
*next_id += 1;
|
*next_id += 1;
|
||||||
|
|
||||||
let device = DSPDevice {
|
let device = DSPDevice { dsp };
|
||||||
dsp,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut devices = state
|
let mut devices = state.devices.lock().map_err(|e| e.to_string())?;
|
||||||
.devices
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
devices.insert(
|
devices.insert(id, Arc::new(Mutex::new(device)));
|
||||||
id,
|
|
||||||
Arc::new(Mutex::new(device)),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_dsp408(
|
pub async fn remove_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
|
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
|
|
||||||
if device.dsp.connected() {
|
if device.dsp.connected() {
|
||||||
device.dsp
|
device.dsp.disconnect().map_err(|e| e.to_string())?;
|
||||||
.disconnect()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -117,28 +94,16 @@ pub async fn remove_dsp408(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect_dsp408(
|
pub async fn get_dsp_snapshot(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: u64,
|
id: u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<DSP408Snapshot, String> {
|
||||||
|
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
device.dsp
|
Ok(DSP408Snapshot {
|
||||||
.connect()
|
state: device.dsp.state().clone(),
|
||||||
.map_err(|e| e.to_string())
|
input_meters: device.dsp.input_meters().clone(),
|
||||||
})
|
output_meters: device.dsp.output_meters().clone(),
|
||||||
}
|
})
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn disconnect_dsp408(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
|
|
||||||
state.with_device_mut(id, |device| {
|
|
||||||
device.dsp
|
|
||||||
.disconnect()
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,64 +112,40 @@ pub async fn get_dsp_state(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: u64,
|
id: u64,
|
||||||
) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
|
) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
|
||||||
|
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
let dsp_state = device.dsp
|
let dsp_state = device.dsp.state();
|
||||||
.state()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(dsp_state.clone())
|
Ok(dsp_state.clone())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_channel_gain(
|
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||||
state: State<'_, AppState>,
|
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
|
||||||
id: u64,
|
}
|
||||||
channel: dsp_thomann::dsp408::types::Channel,
|
|
||||||
db: f32,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
state.with_device_mut(id, |device| {
|
|
||||||
let r = device.dsp
|
|
||||||
.set_channel_gain(channel, db)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(r)
|
// Command `0x11`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device.dsp.disconnect().map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Command `0x15`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_mute(
|
pub async fn set_delay_unit(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: u64,
|
id: u64,
|
||||||
channel: dsp_thomann::dsp408::types::Channel,
|
unit: types::DelayUnit,
|
||||||
muted: bool,
|
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
let r = device.dsp
|
device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
|
||||||
.set_mute(channel, muted)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_channel_inverse_gain(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: u64,
|
|
||||||
channel: dsp_thomann::dsp408::types::Channel,
|
|
||||||
inverted: bool,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
state.with_device_mut(id, |device| {
|
|
||||||
let r = device.dsp
|
|
||||||
.set_channel_inverse_gain(channel, inverted)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(r)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Command `0x20`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn recall_preset(
|
pub async fn recall_preset(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -221,19 +162,35 @@ pub async fn recall_preset(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Command `0x21` -- TODO
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_meter_levels(
|
pub async fn store_current_configuration(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: u64,
|
id: u64,
|
||||||
) -> Result<dsp_thomann::dsp408::types::Meters, String> {
|
preset_index: usize,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
device
|
device
|
||||||
.dsp
|
.dsp
|
||||||
.get_meter_levels()
|
.store_current_configuration(preset_index, force)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Command `0x23`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn factory_reset(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device.dsp.factory_reset(force).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x26`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_current_preset_name(
|
pub async fn set_current_preset_name(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -246,4 +203,373 @@ pub async fn set_current_preset_name(
|
|||||||
.set_current_preset_name(&name)
|
.set_current_preset_name(&name)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Command `0x2A`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn copy_channel(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel_1: types::Channel,
|
||||||
|
channel_2: types::Channel,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.copy_channel(channel_1, channel_2)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x2D` -- TODO
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unlock_device(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
password: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.unlock_device(&password)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x2F`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn lock_device(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
password: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.lock_device(&password)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x30`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_compressor(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::OutputChannel,
|
||||||
|
ratio: types::Ratio,
|
||||||
|
attack: u16,
|
||||||
|
release: u16,
|
||||||
|
knee: u16,
|
||||||
|
threshold: f32,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_compressor(channel, ratio, attack, release, knee, threshold)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x31`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_low_pass(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::Channel,
|
||||||
|
freq: f32,
|
||||||
|
filter_type: types::CrossoverFilter,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_low_pass(channel, freq, filter_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x32`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_high_pass(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::Channel,
|
||||||
|
freq: f32,
|
||||||
|
filter_type: types::CrossoverFilter,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_high_pass(channel, freq, filter_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x33`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_peq_band(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::Channel,
|
||||||
|
band: types::PEQBand,
|
||||||
|
gain_db: f32,
|
||||||
|
freq_hz: f32,
|
||||||
|
q: f32,
|
||||||
|
filter_type: types::PEQFilter,
|
||||||
|
bypass: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_peq_band(channel, band, gain_db, freq_hz, q, filter_type, bypass)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x34`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_channel_gain(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: dsp_thomann::dsp408::types::Channel,
|
||||||
|
db: f32,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
let r = device
|
||||||
|
.dsp
|
||||||
|
.set_channel_gain(channel, db)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x35`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_mute(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: dsp_thomann::dsp408::types::Channel,
|
||||||
|
muted: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
let r = device
|
||||||
|
.dsp
|
||||||
|
.set_mute(channel, muted)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x36`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_channel_inverse_gain(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: dsp_thomann::dsp408::types::Channel,
|
||||||
|
inverted: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
let r = device
|
||||||
|
.dsp
|
||||||
|
.set_channel_inverse_gain(channel, inverted)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x38`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_delay(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::Channel,
|
||||||
|
delay: f32,
|
||||||
|
unit: DelayUnit,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_delay(channel, delay, unit)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x39`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_input_source(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
input_source: types::InputSource,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_input_source(input_source)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x3A`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_matrix_routing(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
output: types::OutputChannel,
|
||||||
|
inputs: Vec<types::InputChannel>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_matrix_routing(output, inputs)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x3B`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn link_channels(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
source: types::Channel,
|
||||||
|
destinations: Vec<types::Channel>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.link_channels(source, destinations)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x3C`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_bypass_peq(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::Channel,
|
||||||
|
bypass: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_bypass_peq(channel, bypass)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x3D` -- TODO
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_channel_name(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::Channel,
|
||||||
|
name: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_channel_name(channel, &name)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x3E`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_gate(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::InputChannel,
|
||||||
|
threshold: f32,
|
||||||
|
attack: u16,
|
||||||
|
hold: u16,
|
||||||
|
release: u16,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_gate(channel, threshold, attack, hold, release)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x3F`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_limiter(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
channel: types::OutputChannel,
|
||||||
|
threshold: f32,
|
||||||
|
attack: u16,
|
||||||
|
release: u16,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_limiter(channel, threshold, attack, release)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x40`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_meter_levels(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
) -> Result<(types::InputMeters, types::OutputMeters), String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device.dsp.get_meter_levels().map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x41`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_matrix_gain(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
input_channel: types::InputChannel,
|
||||||
|
output_channel: types::OutputChannel,
|
||||||
|
gain: f32,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_matrix_gain(input_channel, output_channel, gain)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x48`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_geq_band(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
input_channel: types::InputChannel,
|
||||||
|
frequency_band: types::DiscreteFrequency,
|
||||||
|
gain: f32,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_geq_band(input_channel, frequency_band, gain)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command `0x49`
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_geq_bypass(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
input_channel: types::InputChannel,
|
||||||
|
bypass: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
device
|
||||||
|
.dsp
|
||||||
|
.set_geq_bypass(input_channel, bypass)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod state;
|
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod state;
|
||||||
|
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
@@ -9,19 +9,40 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::create_dsp408,
|
commands::create_dsp408,
|
||||||
commands::connect_dsp408,
|
commands::remove_dsp408,
|
||||||
commands::remove_dsp408,
|
commands::get_dsp_snapshot,
|
||||||
commands::disconnect_dsp408,
|
commands::get_dsp_state,
|
||||||
commands::get_dsp_state,
|
commands::connect_dsp408, // 0x10
|
||||||
commands::set_channel_gain,
|
commands::disconnect_dsp408, // 0x11
|
||||||
commands::set_mute,
|
commands::set_delay_unit, // 0x15
|
||||||
commands::set_channel_inverse_gain,
|
commands::recall_preset, // 0x20
|
||||||
commands::recall_preset,
|
commands::store_current_configuration, // 0x21
|
||||||
commands::get_meter_levels,
|
commands::factory_reset, // 0x23
|
||||||
commands::set_current_preset_name,
|
commands::set_current_preset_name, // 0x26
|
||||||
]
|
commands::copy_channel, // 0x2A
|
||||||
)
|
commands::unlock_device, // Command `0x2D`
|
||||||
|
commands::lock_device, // Command `0x2F`
|
||||||
|
commands::set_compressor, // 0x30
|
||||||
|
commands::set_low_pass, // 0x31
|
||||||
|
commands::set_high_pass, // 0x32
|
||||||
|
commands::set_peq_band, // 0x33
|
||||||
|
commands::set_channel_gain, // 0x34
|
||||||
|
commands::set_mute, // 0x35
|
||||||
|
commands::set_channel_inverse_gain, // 0x36
|
||||||
|
commands::set_delay, // 0x38
|
||||||
|
commands::set_input_source, // 0x39
|
||||||
|
commands::set_matrix_routing, // 0x3A
|
||||||
|
commands::link_channels, // 0x3B
|
||||||
|
commands::set_bypass_peq, // 0x3C
|
||||||
|
commands::set_channel_name, // 0x3D
|
||||||
|
commands::set_gate, // 0x3E
|
||||||
|
commands::set_limiter, // 0x3F
|
||||||
|
commands::get_meter_levels, // 0x40
|
||||||
|
commands::set_matrix_gain, // 0x41
|
||||||
|
commands::set_geq_band, // 0x48
|
||||||
|
commands::set_geq_bypass, // 0x49
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
thomann_dsp_app_lib::run()
|
thomann_dsp_app_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::{Mutex, Arc};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use dsp_thomann::dsp408::DSP408;
|
use dsp_thomann::dsp408::DSP408;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
pub struct DSPDevice {
|
pub struct DSPDevice {
|
||||||
pub dsp: DSP408,
|
pub dsp: DSP408,
|
||||||
@@ -18,4 +18,4 @@ impl AppState {
|
|||||||
next_id: Mutex::new(1),
|
next_id: Mutex::new(1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "thomann-dsp-app",
|
"productName": "thomann-dsp-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.lucas.thomann-dsp-app",
|
"identifier": "com.lucas.thomann-dsp-app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run build",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "thomann-dsp-app",
|
"title": "Thomann DSP Controller",
|
||||||
"width": 1200,
|
"width": 1600,
|
||||||
"height": 800,
|
"height": 950,
|
||||||
"minWidth": 900,
|
"minWidth": 1200,
|
||||||
"minHeight": 600
|
"minHeight": 600
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"./icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"./icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"./icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"./icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"./icons/icon.ico"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { DSP, AppPage } from './types/types';
|
import { DSP, AppPage, isConnected } from './types';
|
||||||
import TopBar from './components/Topbar/Topbar';
|
import TopBar from './components/Topbar/Topbar';
|
||||||
import DSPPage from './pages/DspPage/DspPage';
|
import DSPPage from './pages/DspPage/DspPage';
|
||||||
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
||||||
@@ -8,17 +8,33 @@ import { useNotifications } from './hooks/useNotifications';
|
|||||||
import Notifications from './components/Notifications/Notifications';
|
import Notifications from './components/Notifications/Notifications';
|
||||||
import CreditsPage from './pages/CreditsPage/CreditsPage';
|
import CreditsPage from './pages/CreditsPage/CreditsPage';
|
||||||
import HomePage from './pages/HomePage/HomePage';
|
import HomePage from './pages/HomePage/HomePage';
|
||||||
import { useDSP408 } from './hooks/useDSP408';
|
import { addDSP, createDSPInstance } from './dsp/dspApi';
|
||||||
|
import { removeDSP } from './dsp/dspShared';
|
||||||
|
import { stopAllPolling } from './dsp/pollRegistry';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [dsps, setDsps] = useState<DSP[]>([]);
|
const [dsps, setDsps] = useState<DSP[]>([]);
|
||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
const [page, setPage] = useState<AppPage>('home');
|
const [page, setPage] = useState<AppPage>('home');
|
||||||
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
|
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
|
||||||
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
|
const connectedDspCount = dsps.filter((dsp) => isConnected(dsp)).length;
|
||||||
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
|
|
||||||
|
|
||||||
const dsp408 = useDSP408(dsps, setDsps, selected, setSelected, setPage, notify);
|
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
|
||||||
|
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
|
||||||
|
|
||||||
|
const dspsRef = useRef(dsps);
|
||||||
|
dspsRef.current = dsps;
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopAllPolling(dspsRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeDsp == null) return;
|
||||||
|
const instance = createDSPInstance(activeDsp, setDsps, notify);
|
||||||
|
instance.api.setActive(true);
|
||||||
|
return () => instance.api.setActive(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeDsp?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -34,29 +50,26 @@ function App() {
|
|||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
setSelect={setSelected}
|
setSelect={setSelected}
|
||||||
|
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
setSelected(id);
|
setSelected(id);
|
||||||
setPage('view-dsp');
|
setPage('view-dsp');
|
||||||
}}
|
}}
|
||||||
|
onRemove={(dsp) => removeDSP(setDsps, selected, setSelected, dsp.id, dsp.type)}
|
||||||
onRemove={dsp408.removeDSP}
|
onAddClick={() => setPage('add-dsp')}
|
||||||
|
onConnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.connect()}
|
||||||
onAddClick={() => {
|
onDisconnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.disconnect()}
|
||||||
setPage('add-dsp');
|
|
||||||
}}
|
|
||||||
|
|
||||||
onConnect={dsp408.connectDSP}
|
|
||||||
onDisconnect={dsp408.disconnectDSP}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="stage">
|
<main className="stage">
|
||||||
{page === 'credits' ? (
|
{page === 'credits' ? (
|
||||||
<CreditsPage />
|
<CreditsPage />
|
||||||
) : page === 'add-dsp' ? (
|
) : page === 'add-dsp' ? (
|
||||||
<AddDSPForm onCancel={() => setPage('home')} onAdd={dsp408.addDSP} />
|
<AddDSPForm
|
||||||
) : activeDsp ? (
|
onCancel={() => setPage('home')}
|
||||||
<DSPPage dsp={activeDsp} dsp408={dsp408.forDSP(activeDsp)} />
|
onAdd={(params) => addDSP(setDsps, setSelected, setPage, notify, params)}
|
||||||
|
/>
|
||||||
|
) : activeInstance ? (
|
||||||
|
<DSPPage instance={activeInstance} />
|
||||||
) : (
|
) : (
|
||||||
<HomePage
|
<HomePage
|
||||||
onAddDSP={() => setPage('add-dsp')}
|
onAddDSP={() => setPage('add-dsp')}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { DSPState, Meters } from '../types/dsp408State';
|
|
||||||
|
|
||||||
export const createDSP408 = (ip: string, port: number, deviceId: number) =>
|
|
||||||
invoke<number>('create_dsp408', {
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
deviceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const removeDSP408 = (id: number) => invoke('remove_dsp408', { id });
|
|
||||||
|
|
||||||
export const connectDSP408 = (id: number) => invoke('connect_dsp408', { id });
|
|
||||||
|
|
||||||
export const disconnectDSP408 = (id: number) => invoke('disconnect_dsp408', { id });
|
|
||||||
|
|
||||||
export const getDSP408State = (id: number) => invoke<DSPState>('get_dsp_state', { id });
|
|
||||||
|
|
||||||
export const recallPresetDSP408 = (id: number, presetIndex: number) =>
|
|
||||||
invoke<boolean>('recall_preset', {
|
|
||||||
id,
|
|
||||||
presetIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DSP408SetCurrentPresetName = (id: number, name: string) =>
|
|
||||||
invoke<boolean>('set_current_preset_name', {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });
|
|
||||||
@@ -64,3 +64,172 @@ export function KnobGainIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TextIcon({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.SVGProps<SVGSVGElement> & { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props}>
|
||||||
|
<text
|
||||||
|
x="12"
|
||||||
|
y="12"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize="9.5"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlidersVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path d="M10 8H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M12 21V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M12 8V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
|
||||||
|
<path d="M17 16H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M19 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M19 21V16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
|
||||||
|
<path d="M3 14H7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M5 10V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M5 21V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 12H7M17 12H21"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 4.5L17 12L7 19.5V4.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3V21H21"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 21H12L20 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3V21H21"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 21L11 13L21 8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3V21H21"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 21L11 8H21"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M4 5V19M12 8.5V15.5M20 11V13"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path d="M3 7C11 7 11 4 21 4" />
|
||||||
|
<path d="M3 7C11 7 11 20 21 20" />
|
||||||
|
<path d="M3 17C11 17 11 12 21 12" />
|
||||||
|
<circle cx="3" cy="7" r="1.6" fill="none" />
|
||||||
|
<circle cx="3" cy="17" r="1.6" fill="none" />
|
||||||
|
<circle cx="21" cy="4" r="1.6" fill="none" />
|
||||||
|
<circle cx="21" cy="12" r="1.6" fill="none" />
|
||||||
|
<circle cx="21" cy="20" r="1.6" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M2 12C2 12 5.5 5 12 5C18.5 5 22 12 22 12C22 12 18.5 19 12 19C5.5 19 2 12 2 12Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3L21 21"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.6 5.2C11.05 5.07 11.52 5 12 5C18.5 5 22 12 22 12C22 12 20.7 14.6 18.3 16.5M6.2 6.2C3.5 8.1 2 12 2 12C2 12 5.5 19 12 19C13.2 19 14.3 18.8 15.3 18.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.9 9.9C9.35 10.45 9 11.2 9 12C9 13.66 10.34 15 12 15C12.8 15 13.55 14.65 14.1 14.1"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M8 3L2 12H14L8 3Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
fill="transparent"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M8 13L2 4H14L8 13Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
fill="transparent"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,21 +2,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
min-height: var(--card-control-height);
|
min-height: var(--card-control-height);
|
||||||
|
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
|
|
||||||
border: var(--border-width) solid transparent;
|
border: var(--border-width) solid transparent;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--font-md);
|
font-size: 13px;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@@ -24,27 +24,60 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Variants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent-brand);
|
background: var(--accent-brand);
|
||||||
color: var(--bg-void);
|
color: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ghost + Toggle */
|
||||||
.btn-ghost,
|
.btn-ghost,
|
||||||
.btn-toggle,
|
.btn-toggle {
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: var(--border-hairline);
|
border-color: var(--border-hairline);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Outline */
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Hover */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--accent-brand) 95%, white);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled),
|
.btn-ghost:hover:not(:disabled),
|
||||||
.btn-toggle:hover:not(:disabled),
|
.btn-toggle:hover:not(:disabled) {
|
||||||
.btn-outline:hover:not(:disabled) {
|
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active toggle states */
|
.btn-outline:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Active toggle states */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.btn-toggle.active-info {
|
||||||
|
background: var(--accent-brand);
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
color: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-toggle.active-danger {
|
.btn-toggle.active-danger {
|
||||||
background: var(--accent-down);
|
background: var(--accent-down);
|
||||||
border-color: var(--accent-down);
|
border-color: var(--accent-down);
|
||||||
@@ -57,7 +90,15 @@
|
|||||||
color: var(--bg-void);
|
color: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optional: active state hover */
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Active toggle hover */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.btn-toggle.active-info:hover:not(:disabled) {
|
||||||
|
background: var(--accent-brand);
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-toggle.active-danger:hover:not(:disabled) {
|
.btn-toggle.active-danger:hover:not(:disabled) {
|
||||||
background: var(--accent-down);
|
background: var(--accent-down);
|
||||||
border-color: var(--accent-down);
|
border-color: var(--accent-down);
|
||||||
@@ -66,4 +107,4 @@
|
|||||||
.btn-toggle.active-warning:hover:not(:disabled) {
|
.btn-toggle.active-warning:hover:not(:disabled) {
|
||||||
background: var(--accent-connecting);
|
background: var(--accent-connecting);
|
||||||
border-color: var(--accent-connecting);
|
border-color: var(--accent-connecting);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
fontSize?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
@@ -15,7 +14,6 @@ export default function Button({
|
|||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
disabled,
|
disabled,
|
||||||
fontSize = 13,
|
|
||||||
type = 'button',
|
type = 'button',
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
@@ -26,7 +24,6 @@ export default function Button({
|
|||||||
className={`btn btn-${variant} ${className}`}
|
className={`btn btn-${variant} ${className}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${fontSize}px`,
|
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
.channel-group {
|
.channel-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
padding: 6cqh var(--space-3) 1cqh;
|
padding: 6cqh var(--space-3) 1cqh;
|
||||||
margin-bottom: 1cqh;
|
margin-bottom: 1cqh;
|
||||||
margin-top: 1cqh;
|
margin-top: 1cqh;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: visible; /* in case something upstream set hidden */
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-group__label {
|
.channel-group__label {
|
||||||
@@ -36,6 +39,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/* ChoiceSelect.css */
|
||||||
|
.choice-select {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select[data-disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__trigger {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
padding-inline: 5%;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 90%;
|
||||||
|
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__trigger:hover,
|
||||||
|
.choice-select__trigger--open {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__trigger:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 4cqh;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
transition: transform var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__trigger--open .choice-select__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__chevron svg {
|
||||||
|
width: 50%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__option {
|
||||||
|
padding: 1%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 100%;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__option--highlight {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__option--selected {
|
||||||
|
color: var(--accent-brand);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__listbox {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
|
||||||
|
top: calc(100% + var(--space-1));
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-1);
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__listbox--up {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% + var(--space-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-select__listbox--portal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000; /* clear of any local stacking contexts in the panel */
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// ChoiceSelect.tsx
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||||
|
import './ChoiceSelect.css';
|
||||||
|
|
||||||
|
type ChoiceSelectProps<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
options: readonly T[];
|
||||||
|
formatLabel?: (value: T) => string;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LISTBOX_MAX_HEIGHT = 240; // keep in sync with .choice-select__listbox max-height
|
||||||
|
const LISTBOX_GAP = 4; // px, ~= var(--space-1)
|
||||||
|
|
||||||
|
type Coords = { top: number; left: number; width: number; fontSize: number };
|
||||||
|
|
||||||
|
function ChoiceSelect<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
formatLabel = (v) => v,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
}: ChoiceSelectProps<T>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [highlight, setHighlight] = useState(0);
|
||||||
|
const [dropUp, setDropUp] = useState(false);
|
||||||
|
const [coords, setCoords] = useState<Coords | null>(null);
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const listboxRef = useRef<HTMLUListElement>(null);
|
||||||
|
const suppressNextClick = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
const target = e.target as Node;
|
||||||
|
const clickedOutside =
|
||||||
|
rootRef.current &&
|
||||||
|
!rootRef.current.contains(target) &&
|
||||||
|
!listboxRef.current?.contains(target);
|
||||||
|
|
||||||
|
if (clickedOutside && open) {
|
||||||
|
setOpen(false);
|
||||||
|
suppressNextClick.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickCapture(e: MouseEvent) {
|
||||||
|
if (suppressNextClick.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
suppressNextClick.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleMouseDown);
|
||||||
|
document.addEventListener('click', handleClickCapture, true); // capture phase
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
document.removeEventListener('click', handleClickCapture, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Re-check placement/position on scroll/resize while open — the trigger's
|
||||||
|
// position can change (e.g. inside a scrollable panel), and since the
|
||||||
|
// listbox is portaled it won't move with it automatically.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const recalc = () => updatePlacement();
|
||||||
|
window.addEventListener('scroll', recalc, true);
|
||||||
|
window.addEventListener('resize', recalc);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', recalc, true);
|
||||||
|
window.removeEventListener('resize', recalc);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const step = (direction: 1 | -1) => {
|
||||||
|
const currentIndex = options.indexOf(value);
|
||||||
|
if (currentIndex === -1 || options.length === 0) return;
|
||||||
|
const nextIndex = (currentIndex + direction + options.length) % options.length;
|
||||||
|
onChange(options[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
function updatePlacement() {
|
||||||
|
const el = triggerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
|
||||||
|
setDropUp(needsFlip);
|
||||||
|
|
||||||
|
// Capture the trigger's resolved font-size (its cqh has already resolved
|
||||||
|
// correctly here, since it's still inside the real container tree) and
|
||||||
|
// carry it as a plain px value into the portal, where cqh would break.
|
||||||
|
const fontSize = parseFloat(getComputedStyle(el).fontSize);
|
||||||
|
|
||||||
|
setCoords({
|
||||||
|
top: needsFlip ? rect.top - LISTBOX_GAP : rect.bottom + LISTBOX_GAP,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openList() {
|
||||||
|
if (disabled) return;
|
||||||
|
updatePlacement();
|
||||||
|
setOpen(true);
|
||||||
|
const idx = options.indexOf(value);
|
||||||
|
setHighlight(idx >= 0 ? idx : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(opt: T) {
|
||||||
|
if (opt !== value) onChange(opt);
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
openList();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlight((h) => Math.min(h + 1, options.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlight((h) => Math.max(h - 1, 0));
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(options[highlight]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`choice-select ${className}`}
|
||||||
|
data-disabled={disabled || undefined}
|
||||||
|
ref={rootRef}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
ref={triggerRef}
|
||||||
|
className={'choice-select__trigger' + (open ? ' choice-select__trigger--open' : '')}
|
||||||
|
onClick={() => (open ? setOpen(false) : openList())}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (disabled) return;
|
||||||
|
if (e.deltaY < 0) step(-1);
|
||||||
|
else if (e.deltaY > 0) step(1);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="choice-select__label">{formatLabel(value)}</span>
|
||||||
|
<span className="choice-select__chevron" aria-hidden="true">
|
||||||
|
<TriangleDownIcon />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
coords &&
|
||||||
|
createPortal(
|
||||||
|
<ul
|
||||||
|
ref={listboxRef}
|
||||||
|
className={
|
||||||
|
'choice-select__listbox choice-select__listbox--portal' +
|
||||||
|
(dropUp ? ' choice-select__listbox--up' : '')
|
||||||
|
}
|
||||||
|
role="listbox"
|
||||||
|
style={{
|
||||||
|
fontSize: coords.fontSize,
|
||||||
|
left: coords.left,
|
||||||
|
width: coords.width,
|
||||||
|
// dropUp needs top: auto + bottom instead, see CSS note below
|
||||||
|
...(dropUp
|
||||||
|
? { top: 'auto', bottom: window.innerHeight - coords.top }
|
||||||
|
: { top: coords.top }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option, i) => (
|
||||||
|
<li
|
||||||
|
key={option}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option === value}
|
||||||
|
className={
|
||||||
|
'choice-select__option' +
|
||||||
|
(option === value ? ' choice-select__option--selected' : '') +
|
||||||
|
(i === highlight ? ' choice-select__option--highlight' : '')
|
||||||
|
}
|
||||||
|
onMouseEnter={() => setHighlight(i)}
|
||||||
|
onClick={() => pick(option)}
|
||||||
|
>
|
||||||
|
{formatLabel(option)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChoiceSelect;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
.choice-stepper {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font--sm);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrows {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
padding-right: 1%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrow-btn {
|
||||||
|
height: 50%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrow-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__arrow-btn svg {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper[data-disabled] {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper__input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-stepper[data-disabled] .choice-stepper__input,
|
||||||
|
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
|
||||||
|
color: var(--border-hairline);
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { JSX, useEffect, useRef, useState } from 'react';
|
||||||
|
import Button from '../Button/Button';
|
||||||
|
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||||
|
import './ChoiceStepper.css';
|
||||||
|
|
||||||
|
const WHEEL_COMMIT_DELAY = 250;
|
||||||
|
// Safety net: if the parent's `value` prop never comes back exactly equal
|
||||||
|
// to what we committed (rounding, an intermediate transform, etc.), stop
|
||||||
|
// waiting after this long so the stepper doesn't get stuck ignoring
|
||||||
|
// external updates.
|
||||||
|
const PENDING_FALLBACK_MS = 1000;
|
||||||
|
|
||||||
|
type CommitResult = Promise<boolean | void> | boolean | void;
|
||||||
|
|
||||||
|
function useNonPassiveWheel(
|
||||||
|
ref: React.RefObject<HTMLElement | null>,
|
||||||
|
handler: (e: WheelEvent) => void
|
||||||
|
) {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
handlerRef.current = handler;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const listener = (e: WheelEvent) => handlerRef.current(e);
|
||||||
|
el.addEventListener('wheel', listener, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', listener);
|
||||||
|
}, [ref]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperArrows({
|
||||||
|
onUp,
|
||||||
|
onDown,
|
||||||
|
canUp,
|
||||||
|
canDown,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onUp: () => void;
|
||||||
|
onDown: () => void;
|
||||||
|
canUp: boolean;
|
||||||
|
canDown: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="choice-stepper__arrows">
|
||||||
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className="choice-stepper__arrow-btn"
|
||||||
|
onClick={onUp}
|
||||||
|
disabled={disabled || !canUp}
|
||||||
|
>
|
||||||
|
<TriangleUpIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="toggle"
|
||||||
|
className="choice-stepper__arrow-btn"
|
||||||
|
onClick={onDown}
|
||||||
|
disabled={disabled || !canDown}
|
||||||
|
>
|
||||||
|
<TriangleDownIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- List variant ----
|
||||||
|
|
||||||
|
type ChoiceStepperListProps<T> = {
|
||||||
|
value: T;
|
||||||
|
values: readonly T[];
|
||||||
|
formatLabel: (value: T) => string;
|
||||||
|
onChange: (value: T) => CommitResult;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChoiceStepperList<T>({
|
||||||
|
value,
|
||||||
|
values,
|
||||||
|
formatLabel,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: ChoiceStepperListProps<T>) {
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
const [text, setText] = useState(formatLabel(value));
|
||||||
|
|
||||||
|
// True from the moment a commit starts until the prop `value` we get from
|
||||||
|
// the parent actually reflects it (not just until onChange resolves —
|
||||||
|
// those are two different moments and the gap between them is what was
|
||||||
|
// causing the old-value blink).
|
||||||
|
const interacting = useRef(false);
|
||||||
|
// The value we're waiting to see echoed back via the `value` prop.
|
||||||
|
const pendingValue = useRef<T | null>(null);
|
||||||
|
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useNonPassiveWheel(inputRef, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.deltaY < 0 && canUp) {
|
||||||
|
stepWheel(1);
|
||||||
|
} else if (e.deltaY > 0 && canDown) {
|
||||||
|
stepWheel(-1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interacting.current) {
|
||||||
|
// Only release control once the prop actually catches up to what we
|
||||||
|
// committed — otherwise keep showing localValue as-is (no flash back
|
||||||
|
// to the stale old value in between).
|
||||||
|
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}, [value, formatLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const index = values.indexOf(localValue);
|
||||||
|
const canUp = index === -1 || index < values.length - 1;
|
||||||
|
const canDown = index > 0;
|
||||||
|
|
||||||
|
const stepLocal = (direction: 1 | -1): T | null => {
|
||||||
|
const currentIndex = values.indexOf(localValue);
|
||||||
|
if (currentIndex === -1) return null;
|
||||||
|
|
||||||
|
const nextIndex = currentIndex + direction;
|
||||||
|
if (nextIndex < 0 || nextIndex >= values.length) return null;
|
||||||
|
|
||||||
|
const next = values[nextIndex];
|
||||||
|
setLocalValue(next);
|
||||||
|
setText(formatLabel(next));
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function commit(next: T) {
|
||||||
|
interacting.current = true;
|
||||||
|
pendingValue.current = next;
|
||||||
|
|
||||||
|
const result = await onChange(next);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepted: don't clear `interacting` here. Wait for the prop-sync
|
||||||
|
// effect above to see `value === next` and release it, so we never
|
||||||
|
// hand control back to a still-stale prop. Guard with a timeout in
|
||||||
|
// case it never matches exactly.
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
fallbackTimeout.current = setTimeout(() => {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
}, PENDING_FALLBACK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepAndCommit = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next !== null) void commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepWheel = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next === null) return;
|
||||||
|
|
||||||
|
interacting.current = true;
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
wheelTimeout.current = setTimeout(() => {
|
||||||
|
wheelTimeout.current = null;
|
||||||
|
void commit(next);
|
||||||
|
}, WHEEL_COMMIT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitText = () => {
|
||||||
|
const match = values.find(
|
||||||
|
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
if (match !== undefined) {
|
||||||
|
setLocalValue(match);
|
||||||
|
void commit(match);
|
||||||
|
} else {
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
className="choice-stepper__input"
|
||||||
|
value={text}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commitText}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
interacting.current = true;
|
||||||
|
stepLocal(1);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
interacting.current = true;
|
||||||
|
stepLocal(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
void commit(localValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StepperArrows
|
||||||
|
onUp={() => stepAndCommit(1)}
|
||||||
|
onDown={() => stepAndCommit(-1)}
|
||||||
|
canUp={canUp}
|
||||||
|
canDown={canDown}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Range variant (concrete, no generic) ----
|
||||||
|
|
||||||
|
type ChoiceStepperRangeProps = {
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
formatLabel: (value: number) => string;
|
||||||
|
onChange: (value: number) => CommitResult;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChoiceStepperRange({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step: stepSize = 1,
|
||||||
|
formatLabel,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: ChoiceStepperRangeProps) {
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
const [text, setText] = useState(formatLabel(value));
|
||||||
|
|
||||||
|
const interacting = useRef(false);
|
||||||
|
const pendingValue = useRef<number | null>(null);
|
||||||
|
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useNonPassiveWheel(inputRef, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.deltaY < 0 && canUp) {
|
||||||
|
stepWheel(1);
|
||||||
|
} else if (e.deltaY > 0 && canDown) {
|
||||||
|
stepWheel(-1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interacting.current) {
|
||||||
|
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}, [value, formatLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clamp = (n: number) => Math.min(max, Math.max(min, n));
|
||||||
|
|
||||||
|
const canUp = localValue < max;
|
||||||
|
const canDown = localValue > min;
|
||||||
|
|
||||||
|
const stepLocal = (direction: 1 | -1): number | null => {
|
||||||
|
const next = clamp(localValue + direction * stepSize);
|
||||||
|
if (next === localValue) return null;
|
||||||
|
setLocalValue(next);
|
||||||
|
setText(formatLabel(next));
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function commit(next: number) {
|
||||||
|
interacting.current = true;
|
||||||
|
pendingValue.current = next;
|
||||||
|
|
||||||
|
const result = await onChange(next);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
fallbackTimeout.current = setTimeout(() => {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
}, PENDING_FALLBACK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepAndCommit = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next !== null) void commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepWheel = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next === null) return;
|
||||||
|
|
||||||
|
interacting.current = true;
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
wheelTimeout.current = setTimeout(() => {
|
||||||
|
wheelTimeout.current = null;
|
||||||
|
void commit(next);
|
||||||
|
}, WHEEL_COMMIT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitText = () => {
|
||||||
|
const parsed = Number(text);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
const next = clamp(parsed);
|
||||||
|
setLocalValue(next);
|
||||||
|
void commit(next);
|
||||||
|
} else {
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
className="choice-stepper__input"
|
||||||
|
value={text}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commitText}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canUp) {
|
||||||
|
interacting.current = true;
|
||||||
|
stepLocal(1);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canDown) {
|
||||||
|
interacting.current = true;
|
||||||
|
stepLocal(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
void commit(localValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StepperArrows
|
||||||
|
onUp={() => stepAndCommit(1)}
|
||||||
|
onDown={() => stepAndCommit(-1)}
|
||||||
|
canUp={canUp}
|
||||||
|
canDown={canDown}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceStepper<T>(props: ChoiceStepperListProps<T>): JSX.Element;
|
||||||
|
function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element;
|
||||||
|
function ChoiceStepper(props: ChoiceStepperListProps<unknown> | ChoiceStepperRangeProps) {
|
||||||
|
if ('values' in props) {
|
||||||
|
return <ChoiceStepperList {...props} />;
|
||||||
|
}
|
||||||
|
return <ChoiceStepperRange {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChoiceStepper;
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export interface LinearGraphHelpers {
|
||||||
|
toPx: (x: number, y: number) => [number, number];
|
||||||
|
toPxX: (x: number) => number;
|
||||||
|
toPxY: (y: number) => number;
|
||||||
|
toData: (px: number, py: number) => [number, number];
|
||||||
|
plot: { x: number; y: number; width: number; height: number };
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DrawFn = (helpers: LinearGraphHelpers) => void;
|
||||||
|
|
||||||
|
export interface LinearGraphHandle {
|
||||||
|
redraw: () => void;
|
||||||
|
getCanvas: () => HTMLCanvasElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AxisScale = 'linear' | 'log';
|
||||||
|
|
||||||
|
export interface LinearGraphProps {
|
||||||
|
xMin: number;
|
||||||
|
xMax: number;
|
||||||
|
xStep?: number;
|
||||||
|
yMin: number;
|
||||||
|
yMax: number;
|
||||||
|
yStep?: number;
|
||||||
|
/** 'log' requires xMin > 0. */
|
||||||
|
xScale?: AxisScale;
|
||||||
|
/** 'log' requires yMin > 0. */
|
||||||
|
yScale?: AxisScale;
|
||||||
|
draw?: DrawFn;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
padding?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||||
|
showGrid?: boolean;
|
||||||
|
showAxes?: boolean;
|
||||||
|
showTicks?: boolean;
|
||||||
|
xLabel?: string;
|
||||||
|
yLabel?: string;
|
||||||
|
formatX?: (v: number) => string;
|
||||||
|
formatY?: (v: number) => string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFormat = (v: number) => {
|
||||||
|
if (Math.abs(v) < 1e-9) return '0';
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
|
||||||
|
return parseFloat(v.toFixed(4)).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
function niceStep(min: number, max: number, targetTicks = 8): number {
|
||||||
|
const range = Math.abs(max - min) || 1;
|
||||||
|
const raw = range / targetTicks;
|
||||||
|
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||||
|
const norm = raw / mag;
|
||||||
|
const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
|
||||||
|
return step * mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function range(min: number, max: number, step: number): number[] {
|
||||||
|
if (step <= 0) return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
const start = Math.ceil((min - 1e-9) / step) * step;
|
||||||
|
for (let v = start; v <= max + 1e-9; v += step) {
|
||||||
|
out.push(Math.round(v / step) * step);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tick {
|
||||||
|
value: number;
|
||||||
|
/** Decade lines (powers of 10) get major=true for a bolder/brighter grid line. */
|
||||||
|
major: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear ticks: every step is equally weighted, so mark every Nth (default 5th) as "major"
|
||||||
|
// purely for label emphasis — grid highlighting logic only really matters for log mode.
|
||||||
|
function linearTicks(min: number, max: number, step: number): Tick[] {
|
||||||
|
return range(min, max, step).map((value) => ({ value, major: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log ticks: standard 1/2/3/4/5/6/7/8/9 mantissas within each decade between min and max.
|
||||||
|
// Mantissa === 1 (i.e. the decade itself: ...0.1, 1, 10, 100, 1000...) is flagged major
|
||||||
|
// so it can be drawn as a highlighted line distinct from the minor in-decade ticks.
|
||||||
|
function logTicks(min: number, max: number): Tick[] {
|
||||||
|
if (min <= 0 || max <= 0 || max <= min) return [];
|
||||||
|
const startDecade = Math.floor(Math.log10(min));
|
||||||
|
const endDecade = Math.ceil(Math.log10(max));
|
||||||
|
const out: Tick[] = [];
|
||||||
|
for (let d = startDecade; d <= endDecade; d++) {
|
||||||
|
const decadeValue = Math.pow(10, d);
|
||||||
|
for (const mantissa of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
|
||||||
|
const value = decadeValue * mantissa;
|
||||||
|
if (value < min - 1e-9 || value > max + 1e-9) continue;
|
||||||
|
out.push({ value, major: mantissa === 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
|
||||||
|
{
|
||||||
|
xMin,
|
||||||
|
xMax,
|
||||||
|
xStep,
|
||||||
|
yMin,
|
||||||
|
yMax,
|
||||||
|
yStep,
|
||||||
|
xScale = 'linear',
|
||||||
|
yScale = 'linear',
|
||||||
|
draw,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
padding,
|
||||||
|
showGrid = true,
|
||||||
|
showAxes = true,
|
||||||
|
showTicks = true,
|
||||||
|
xLabel,
|
||||||
|
yLabel,
|
||||||
|
formatX = defaultFormat,
|
||||||
|
formatY = defaultFormat,
|
||||||
|
style,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
|
||||||
|
|
||||||
|
// "container query" units, computed from the actual rendered size —
|
||||||
|
// 1 cqw = 1% of width, 1 cqh = 1% of height, cqmin = smaller of the two.
|
||||||
|
const cq = useMemo(() => {
|
||||||
|
const w = size.width || 1;
|
||||||
|
const h = size.height || 1;
|
||||||
|
return { w: w / 100, h: h / 100, min: Math.min(w, h) / 100 };
|
||||||
|
}, [size.width, size.height]);
|
||||||
|
|
||||||
|
const pad = useMemo(() => {
|
||||||
|
const topBase = cq.h * 3;
|
||||||
|
const rightBase = cq.w * 5;
|
||||||
|
const bottomBase = cq.h * 4;
|
||||||
|
const leftBase = cq.w * 7;
|
||||||
|
const xLabelSpace = xLabel ? cq.h * 7 : 0;
|
||||||
|
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: padding?.top ?? topBase,
|
||||||
|
right: padding?.right ?? rightBase,
|
||||||
|
bottom: padding?.bottom ?? bottomBase + xLabelSpace,
|
||||||
|
left: padding?.left ?? leftBase + yLabelSpace,
|
||||||
|
};
|
||||||
|
}, [padding, xLabel, yLabel, cq]);
|
||||||
|
|
||||||
|
// font / stroke / tick metrics, all derived from container size
|
||||||
|
const metrics = useMemo(
|
||||||
|
() => ({
|
||||||
|
tickFont: cq.min * 3,
|
||||||
|
labelFont: cq.min * 4,
|
||||||
|
tickLen: cq.min * 1.2,
|
||||||
|
axisLineWidth: cq.min * 0.4,
|
||||||
|
yLabelOffset: cq.w * 2,
|
||||||
|
}),
|
||||||
|
[cq]
|
||||||
|
);
|
||||||
|
|
||||||
|
const effXStep = xStep ?? niceStep(xMin, xMax);
|
||||||
|
const effYStep = yStep ?? niceStep(yMin, yMax);
|
||||||
|
|
||||||
|
const xLogValid = xScale === 'log' && xMin > 0 && xMax > xMin;
|
||||||
|
const yLogValid = yScale === 'log' && yMin > 0 && yMax > yMin;
|
||||||
|
|
||||||
|
const xTicks = useMemo(
|
||||||
|
() => (xLogValid ? logTicks(xMin, xMax) : linearTicks(xMin, xMax, effXStep)),
|
||||||
|
[xLogValid, xMin, xMax, effXStep]
|
||||||
|
);
|
||||||
|
const yTicks = useMemo(
|
||||||
|
() => (yLogValid ? logTicks(yMin, yMax) : linearTicks(yMin, yMax, effYStep)),
|
||||||
|
[yLogValid, yMin, yMax, effYStep]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (width && height) return;
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) return;
|
||||||
|
const { width: w, height: h } = entry.contentRect;
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
setSize({ width: width ?? w, height: height ?? h });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
const paint = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const cssW = size.width;
|
||||||
|
const cssH = size.height;
|
||||||
|
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
|
||||||
|
canvas.width = cssW * dpr;
|
||||||
|
canvas.height = cssH * dpr;
|
||||||
|
}
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
|
const plot = {
|
||||||
|
x: pad.left,
|
||||||
|
y: pad.top,
|
||||||
|
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
|
||||||
|
height: Math.max(1, cssH - pad.top - pad.bottom),
|
||||||
|
};
|
||||||
|
|
||||||
|
const logXMin = xLogValid ? Math.log10(xMin) : 0;
|
||||||
|
const logXMax = xLogValid ? Math.log10(xMax) : 0;
|
||||||
|
const logYMin = yLogValid ? Math.log10(yMin) : 0;
|
||||||
|
const logYMax = yLogValid ? Math.log10(yMax) : 0;
|
||||||
|
|
||||||
|
const toPxX = (x: number) => {
|
||||||
|
if (xLogValid) {
|
||||||
|
const lx = Math.log10(Math.max(x, 1e-9));
|
||||||
|
return plot.x + ((lx - logXMin) / (logXMax - logXMin || 1)) * plot.width;
|
||||||
|
}
|
||||||
|
return plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
|
||||||
|
};
|
||||||
|
const toPxY = (y: number) => {
|
||||||
|
if (yLogValid) {
|
||||||
|
const ly = Math.log10(Math.max(y, 1e-9));
|
||||||
|
return plot.y + plot.height - ((ly - logYMin) / (logYMax - logYMin || 1)) * plot.height;
|
||||||
|
}
|
||||||
|
return plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
|
||||||
|
};
|
||||||
|
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
|
||||||
|
const toData = (px: number, py: number): [number, number] => {
|
||||||
|
const xRatio = (px - plot.x) / plot.width;
|
||||||
|
const yRatio = 1 - (py - plot.y) / plot.height;
|
||||||
|
const x = xLogValid
|
||||||
|
? Math.pow(10, logXMin + xRatio * (logXMax - logXMin))
|
||||||
|
: xMin + xRatio * (xMax - xMin);
|
||||||
|
const y = yLogValid
|
||||||
|
? Math.pow(10, logYMin + yRatio * (logYMax - logYMin))
|
||||||
|
: yMin + yRatio * (yMax - yMin);
|
||||||
|
return [x, y];
|
||||||
|
};
|
||||||
|
|
||||||
|
const css = getComputedStyle(canvas);
|
||||||
|
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
|
||||||
|
const gridMajorColor = css.getPropertyValue('--graph-grid-major').trim() || '#4b5563';
|
||||||
|
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
|
||||||
|
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
|
||||||
|
const bgColor = css.getPropertyValue('--graph-bg').trim();
|
||||||
|
|
||||||
|
if (bgColor) {
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderInset = 1; // Avoid clipping outside graph bounds
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(
|
||||||
|
plot.x + borderInset,
|
||||||
|
plot.y + borderInset,
|
||||||
|
plot.width - borderInset * 2,
|
||||||
|
plot.height - borderInset * 2
|
||||||
|
);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
ctx.strokeStyle = axisColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(
|
||||||
|
Math.round(plot.x) + 0.5,
|
||||||
|
Math.round(plot.y) + 0.5,
|
||||||
|
Math.round(plot.width) - 1,
|
||||||
|
Math.round(plot.height) - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showGrid) {
|
||||||
|
// minor grid lines first (thin), then major/decade lines drawn on top so
|
||||||
|
// they aren't obscured and read clearly as the "multiples of 10" markers.
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const t of xTicks) {
|
||||||
|
if (t.major) continue;
|
||||||
|
const px = Math.round(toPxX(t.value)) + 0.5;
|
||||||
|
ctx.moveTo(px, plot.y);
|
||||||
|
ctx.lineTo(px, plot.y + plot.height);
|
||||||
|
}
|
||||||
|
for (const t of yTicks) {
|
||||||
|
if (t.major) continue;
|
||||||
|
const py = Math.round(toPxY(t.value)) + 0.5;
|
||||||
|
ctx.moveTo(plot.x, py);
|
||||||
|
ctx.lineTo(plot.x + plot.width, py);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const majorX = xTicks.filter((t) => t.major);
|
||||||
|
const majorY = yTicks.filter((t) => t.major);
|
||||||
|
if (majorX.length || majorY.length) {
|
||||||
|
ctx.strokeStyle = gridMajorColor;
|
||||||
|
ctx.lineWidth = Math.max(1, metrics.axisLineWidth * 0.6);
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const t of majorX) {
|
||||||
|
const px = Math.round(toPxX(t.value)) + 0.5;
|
||||||
|
ctx.moveTo(px, plot.y);
|
||||||
|
ctx.lineTo(px, plot.y + plot.height);
|
||||||
|
}
|
||||||
|
for (const t of majorY) {
|
||||||
|
const py = Math.round(toPxY(t.value)) + 0.5;
|
||||||
|
ctx.moveTo(plot.x, py);
|
||||||
|
ctx.lineTo(plot.x + plot.width, py);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAxes) {
|
||||||
|
ctx.strokeStyle = axisColor;
|
||||||
|
ctx.lineWidth = metrics.axisLineWidth;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (!yLogValid && yMin <= 0 && yMax >= 0) {
|
||||||
|
const py = Math.round(toPxY(0)) + 0.5;
|
||||||
|
ctx.moveTo(plot.x, py);
|
||||||
|
ctx.lineTo(plot.x + plot.width, py);
|
||||||
|
}
|
||||||
|
if (!xLogValid && xMin <= 0 && xMax >= 0) {
|
||||||
|
const px = Math.round(toPxX(0)) + 0.5;
|
||||||
|
ctx.moveTo(px, plot.y);
|
||||||
|
ctx.lineTo(px, plot.y + plot.height);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.strokeStyle = axisColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(
|
||||||
|
Math.round(plot.x) + 0.5,
|
||||||
|
Math.round(plot.y) + 0.5,
|
||||||
|
Math.round(plot.width) - 1,
|
||||||
|
Math.round(plot.height) - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showTicks) {
|
||||||
|
ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
for (const t of xTicks) {
|
||||||
|
// In log mode, only label the decade (major) ticks — labeling every
|
||||||
|
// 2,3,4...9 mantissa gets unreadably dense.
|
||||||
|
if (xLogValid && !t.major) continue;
|
||||||
|
const px = toPxX(t.value);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px, plot.y + plot.height);
|
||||||
|
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
|
||||||
|
ctx.strokeStyle = axisColor;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = t.major && xLogValid ? textColor : textColor;
|
||||||
|
ctx.fillText(formatX(t.value), px, plot.y + plot.height + metrics.tickLen + 2);
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (const t of yTicks) {
|
||||||
|
if (yLogValid && !t.major) continue;
|
||||||
|
const py = toPxY(t.value);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(plot.x - metrics.tickLen, py);
|
||||||
|
ctx.lineTo(plot.x, py);
|
||||||
|
ctx.strokeStyle = axisColor;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(formatY(t.value), plot.x - metrics.tickLen - 2, py);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xLabel) {
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
|
||||||
|
}
|
||||||
|
if (yLabel) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
|
ctx.fillText(yLabel, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
size,
|
||||||
|
pad,
|
||||||
|
metrics,
|
||||||
|
xMin,
|
||||||
|
xMax,
|
||||||
|
xLogValid,
|
||||||
|
yMin,
|
||||||
|
yMax,
|
||||||
|
yLogValid,
|
||||||
|
xTicks,
|
||||||
|
yTicks,
|
||||||
|
showGrid,
|
||||||
|
showAxes,
|
||||||
|
showTicks,
|
||||||
|
xLabel,
|
||||||
|
yLabel,
|
||||||
|
formatX,
|
||||||
|
formatY,
|
||||||
|
draw,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
paint();
|
||||||
|
}, [paint]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
redraw: paint,
|
||||||
|
getCanvas: () => canvasRef.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={'container'}
|
||||||
|
style={{
|
||||||
|
width: width ?? '100%',
|
||||||
|
height: height ?? '100%',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={'canvas'}
|
||||||
|
style={{ width: size.width, height: size.height }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LinearGraph;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
.meter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: left;
|
||||||
|
gap: 0.3cqh;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__title {
|
||||||
|
font-size: 1.5cqh;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
justify-content: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.3cqh;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__leds-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 3cqh;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All rows — the 2 indicator LEDs and every level segment — are direct
|
||||||
|
siblings in this one flex column with equal flex weight, so they're
|
||||||
|
guaranteed the exact same height. No row gets a hardcoded pixel height. */
|
||||||
|
.meter-bar__leds {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5cqh;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__led {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.5cqh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background-color 60ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__led--indicator {
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peak-hold marker: a thin line overlaid on the ladder at the peak's position,
|
||||||
|
independent of which LEDs are lit by rms. */
|
||||||
|
.meter-bar__peak-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: -0.5cqh;
|
||||||
|
right: -0.5cqh;
|
||||||
|
height: 0.3cqh;
|
||||||
|
min-height: 1px;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
box-shadow: 0 0 3px rgba(255, 255, 255, 0.8);
|
||||||
|
transform: translateY(0.25cqh);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale sits in the same row as leds-wrap, same height, so ticks positioned
|
||||||
|
by percentage line up with the exact dB position on the ladder. */
|
||||||
|
.meter-bar__scale {
|
||||||
|
position: relative;
|
||||||
|
width: 3.5cqh;
|
||||||
|
font-size: 1.5cqh;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__scale-tick {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__scale-tick--end {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__value {
|
||||||
|
padding-top: 0.5cqh;
|
||||||
|
font-size: 1.2cqh;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__indicator-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1cqh;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
font-size: 1.5cqh;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar__indicator-row .meter-bar__led {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 3cqh;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import './MeterBar.css';
|
||||||
|
|
||||||
|
export interface Meter {
|
||||||
|
rms: number;
|
||||||
|
peak: number;
|
||||||
|
clip?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeterBarProps = {
|
||||||
|
label: string;
|
||||||
|
meter: Meter;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
segments?: number;
|
||||||
|
/** Intermediate dB values to mark on the scale, e.g. [0] or [-20, -6, 0]. */
|
||||||
|
ticks?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function segmentColor(posRatioFromBottom: number) {
|
||||||
|
if (posRatioFromBottom > 0.9) return '#f87171'; // red — near clip
|
||||||
|
if (posRatioFromBottom > 0.7) return '#fbbf24'; // yellow — hot
|
||||||
|
return '#4ade80'; // green — normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats a dB value with an explicit '+' sign for positive values (e.g. +12, -60, 0)
|
||||||
|
function formatDb(v: number, decimals = 0) {
|
||||||
|
if (!Number.isFinite(v)) return '—';
|
||||||
|
const fixed = v.toFixed(decimals);
|
||||||
|
return v > 0 ? `+${fixed}` : fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeterBar({
|
||||||
|
label,
|
||||||
|
meter,
|
||||||
|
min = -60,
|
||||||
|
max = +10,
|
||||||
|
segments = 7,
|
||||||
|
ticks = [0, -50, -40, -30, -20, -10],
|
||||||
|
}: MeterBarProps) {
|
||||||
|
const { rms, peak, clip } = meter;
|
||||||
|
|
||||||
|
// Maps a raw dB value to a 0..1 ratio from the bottom of the ladder, clamped to range.
|
||||||
|
const ratioOf = (v: number) => {
|
||||||
|
const clamped = Math.min(max, Math.max(min, v));
|
||||||
|
return (clamped - min) / (max - min);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RMS ladder (drives which LEDs are lit) ---
|
||||||
|
const rmsRatio = ratioOf(rms);
|
||||||
|
const litCount = Math.round(rmsRatio * segments);
|
||||||
|
|
||||||
|
// --- Peak marker (position only, doesn't affect lit count) ---
|
||||||
|
const peakRatio = ratioOf(peak);
|
||||||
|
|
||||||
|
// --- Intermediate tick marks on the scale, e.g. a 0 dB reference line ---
|
||||||
|
const visibleTicks = ticks.filter((t) => t > min && t < max);
|
||||||
|
|
||||||
|
// --- Red "over" LED: lit whenever rms or peak crosses max ---
|
||||||
|
const isOverMax = rms >= max || peak >= max;
|
||||||
|
|
||||||
|
// --- Orange "limit" LED: lit strictly from the meter's own clip flag.
|
||||||
|
// clip is optional — some meters simply don't report a limiter/clip state.
|
||||||
|
const hasLimiter = clip !== undefined;
|
||||||
|
const isClipping = clip === true;
|
||||||
|
|
||||||
|
// The over-led and limit-led are rendered as two extra rows in the very
|
||||||
|
// same flex column as the level segments (see meter-bar__leds below), so
|
||||||
|
// they get identical row height automatically instead of a hardcoded one.
|
||||||
|
// Because of that, the ladder itself only occupies `segments / totalRows`
|
||||||
|
// of the column — the rest of the math (peak marker, scale ticks) has to
|
||||||
|
// scale down to match.
|
||||||
|
const totalRows = segments + 2;
|
||||||
|
const ladderFraction = segments / totalRows;
|
||||||
|
const toColumnPercent = (ratio: number) => ratio * ladderFraction * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meter-bar">
|
||||||
|
<div className="meter-bar__title">{label}</div>
|
||||||
|
|
||||||
|
<div className="meter-bar__body">
|
||||||
|
<div className="meter-bar__leds-wrap">
|
||||||
|
<div className="meter-bar__leds">
|
||||||
|
<div
|
||||||
|
className="meter-bar__indicator-row"
|
||||||
|
style={{ visibility: hasLimiter ? 'visible' : 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="meter-bar__led meter-bar__led--indicator"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isClipping ? '#fb923c' : 'rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
title="Limit"
|
||||||
|
/>
|
||||||
|
<span>Limit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meter-bar__indicator-row">
|
||||||
|
<div
|
||||||
|
className="meter-bar__led meter-bar__led--indicator"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isOverMax ? '#f87171' : 'rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
title="Clip"
|
||||||
|
/>
|
||||||
|
<span>Clip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.from({ length: segments }).map((_, i) => {
|
||||||
|
// i=0 renders at the top; convert to "position from bottom" for lighting + color
|
||||||
|
const posFromBottom = segments - 1 - i;
|
||||||
|
const lit = posFromBottom < litCount;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="meter-bar__led"
|
||||||
|
style={{
|
||||||
|
backgroundColor: lit
|
||||||
|
? segmentColor(posFromBottom / segments)
|
||||||
|
: 'rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="meter-bar__peak-marker"
|
||||||
|
style={{ bottom: `${toColumnPercent(peakRatio)}%` }}
|
||||||
|
title={`Peak: ${formatDb(peak, 1)} dB`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meter-bar__scale">
|
||||||
|
<span
|
||||||
|
className="meter-bar__scale-tick meter-bar__scale-tick--end"
|
||||||
|
style={{ bottom: `${ladderFraction * 100}%` }}
|
||||||
|
>
|
||||||
|
{formatDb(max)}
|
||||||
|
</span>
|
||||||
|
{visibleTicks.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="meter-bar__scale-tick"
|
||||||
|
style={{ bottom: `${toColumnPercent(ratioOf(t))}%` }}
|
||||||
|
>
|
||||||
|
{formatDb(t)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className="meter-bar__scale-tick meter-bar__scale-tick--end"
|
||||||
|
style={{ bottom: '0%' }}
|
||||||
|
>
|
||||||
|
{formatDb(min)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meter-bar__value">
|
||||||
|
{Number.isFinite(rms) ? `${formatDb(rms, 1)} dB` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MeterBar;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
.notifications {
|
.notifications {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: var(--space-5);
|
right: var(--space-5);
|
||||||
bottom: var(--space-5);
|
top: var(--space-5);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Notification } from '../../types/types';
|
import type { Notification } from '../../types';
|
||||||
import './Notifications.css';
|
import './Notifications.css';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
transition: width var(--dur-base) var(--ease-standard);
|
transition: width var(--dur-base) var(--ease-standard);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-rail--expanded {
|
.sidebar-rail--expanded {
|
||||||
@@ -32,13 +35,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-1);
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-rail--expanded .sidebar-header {
|
.sidebar-rail--expanded .sidebar-header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-inline: var(--space-3);
|
padding-left: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
@@ -361,3 +365,54 @@
|
|||||||
.app-root--sidebar-expanded .stage--with-sidebar {
|
.app-root--sidebar-expanded .stage--with-sidebar {
|
||||||
margin-left: var(--sidebar-width-expanded);
|
margin-left: var(--sidebar-width-expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Item (editing state) ---------- */
|
||||||
|
|
||||||
|
.sidebar-item--editing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sidebar-item-gap);
|
||||||
|
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: calc(var(--header-height) * 0.7);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-input {
|
||||||
|
width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-fast) var(--ease-standard),
|
||||||
|
max-width var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-rail--expanded .sidebar-item-input {
|
||||||
|
width: auto;
|
||||||
|
opacity: 1;
|
||||||
|
max-width: 200px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
||||||
|
|
||||||
export type SidebarItem = {
|
export type SidebarItem<T extends string = string> = {
|
||||||
id: string;
|
id: T;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SidebarAppOption = {
|
export type SidebarAppOption = {
|
||||||
@@ -13,15 +14,79 @@ export type SidebarAppOption = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SidebarButton({
|
function SidebarButton<T extends string>({
|
||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onRename,
|
||||||
}: {
|
}: {
|
||||||
item: SidebarItem;
|
item: SidebarItem<T>;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onRename?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(item.label);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setInputValue(item.label);
|
||||||
|
}, [item.label, editing]);
|
||||||
|
|
||||||
|
function startEditing(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!onRename) return;
|
||||||
|
|
||||||
|
setInputValue(item.label);
|
||||||
|
setEditing(true);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRename() {
|
||||||
|
setEditing(false);
|
||||||
|
|
||||||
|
const trimmed = inputValue.trim();
|
||||||
|
|
||||||
|
if (!trimmed || trimmed === item.label) {
|
||||||
|
setInputValue(item.label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRename?.(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') inputRef.current?.blur();
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setInputValue(item.label);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="sidebar-item sidebar-item--editing">
|
||||||
|
<span className="sidebar-icon">{item.icon}</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="sidebar-item-input"
|
||||||
|
value={inputValue}
|
||||||
|
maxLength={8}
|
||||||
|
onChange={(e) => setInputValue(e.target.value.slice(0, 8))}
|
||||||
|
onBlur={commitRename}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label="Rename channel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
@@ -31,7 +96,14 @@ function SidebarButton({
|
|||||||
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
|
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
|
||||||
>
|
>
|
||||||
<span className="sidebar-icon">{item.icon}</span>
|
<span className="sidebar-icon">{item.icon}</span>
|
||||||
<span className="sidebar-label">{item.label}</span>
|
|
||||||
|
<span
|
||||||
|
className="sidebar-label"
|
||||||
|
onDoubleClick={onRename ? startEditing : undefined}
|
||||||
|
title={onRename ? `${item.label} (double click to rename)` : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,8 +118,8 @@ function AppCombobox({
|
|||||||
appOptions: SidebarAppOption[];
|
appOptions: SidebarAppOption[];
|
||||||
selectedAppId?: string;
|
selectedAppId?: string;
|
||||||
appName: string;
|
appName: string;
|
||||||
onAppChange?: (id: string) => void;
|
onAppChange: (id: string) => void;
|
||||||
onAppRename?: (name: string) => void;
|
onAppRename: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
|
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
|
||||||
const canRename = currentOption?.id !== 'f00';
|
const canRename = currentOption?.id !== 'f00';
|
||||||
@@ -95,6 +167,7 @@ function AppCombobox({
|
|||||||
|
|
||||||
if (!canRename) return;
|
if (!canRename) return;
|
||||||
|
|
||||||
|
setInputValue((currentOption?.name ?? appName).trim());
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
@@ -124,13 +197,13 @@ function AppCombobox({
|
|||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
if (matched.id !== selectedAppId) {
|
if (matched.id !== selectedAppId) {
|
||||||
onAppChange?.(matched.id);
|
onAppChange(matched.id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed !== currentOption?.name) {
|
if (trimmed !== currentOption?.name) {
|
||||||
onAppRename?.(trimmed);
|
onAppRename(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +259,7 @@ function AppCombobox({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
title={canRename ? 'Double click to rename' : undefined}
|
||||||
>
|
>
|
||||||
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
|
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
|
||||||
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
|
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
|
||||||
@@ -209,7 +283,8 @@ function AppCombobox({
|
|||||||
onMouseEnter={() => setHighlight(i)}
|
onMouseEnter={() => setHighlight(i)}
|
||||||
onClick={() => pick(opt)}
|
onClick={() => pick(opt)}
|
||||||
>
|
>
|
||||||
{opt.name}
|
<span className="sidebar-app-option-id">{opt.id + '-'}</span>
|
||||||
|
<span>{opt.name}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -218,7 +293,7 @@ function AppCombobox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export function Sidebar<T extends string>({
|
||||||
items,
|
items,
|
||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -227,17 +302,19 @@ export default function Sidebar({
|
|||||||
selectedAppId,
|
selectedAppId,
|
||||||
onAppChange,
|
onAppChange,
|
||||||
onAppRename,
|
onAppRename,
|
||||||
|
onItemRename,
|
||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
items: SidebarItem[];
|
items: SidebarItem<T>[];
|
||||||
activeId: string | null;
|
activeId: T | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: T) => void;
|
||||||
appName: string;
|
appName: string;
|
||||||
appOptions?: SidebarAppOption[];
|
appOptions?: SidebarAppOption[];
|
||||||
selectedAppId?: string;
|
selectedAppId?: string;
|
||||||
onAppChange?: (id: string) => void;
|
onAppChange: (id: string) => void;
|
||||||
onAppRename?: (name: string) => void;
|
onAppRename: (name: string) => void;
|
||||||
|
onItemRename?: (id: T, name: string) => void;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -295,6 +372,9 @@ export default function Sidebar({
|
|||||||
item={item}
|
item={item}
|
||||||
active={activeId === item.id}
|
active={activeId === item.id}
|
||||||
onSelect={() => onSelect(item.id)}
|
onSelect={() => onSelect(item.id)}
|
||||||
|
onRename={
|
||||||
|
item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.signal-flow {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__chain-line {
|
||||||
|
stroke: var(--border-hairline, #333);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__stage {
|
||||||
|
fill: var(--bg-void);
|
||||||
|
stroke: var(--border-hairline);
|
||||||
|
stroke-width: 1;
|
||||||
|
transition: fill 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__stage:hover {
|
||||||
|
fill: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-flow__stage-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
fill: var(--text-primary, #e5e7eb);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
|
||||||
|
import { DSP408 } from '../../types';
|
||||||
|
import {
|
||||||
|
INPUT_ORDER,
|
||||||
|
OUTPUT_ORDER,
|
||||||
|
MATRIX_GAIN_MIN,
|
||||||
|
MATRIX_GAIN_MAX,
|
||||||
|
MATRIX_GAIN_STEP,
|
||||||
|
} from '../../features/dsp408/constants';
|
||||||
|
import { SidebarId } from '../../features/dsp408/constants';
|
||||||
|
import './SignalFlowMatrix.css';
|
||||||
|
|
||||||
|
const INPUT_SIDEBAR: Record<InputChannel, SidebarId> = {
|
||||||
|
InA: 'ina',
|
||||||
|
InB: 'inb',
|
||||||
|
InC: 'inc',
|
||||||
|
InD: 'ind',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OUTPUT_SIDEBAR: Record<OutputChannel, SidebarId> = {
|
||||||
|
Out1: 'out1',
|
||||||
|
Out2: 'out2',
|
||||||
|
Out3: 'out3',
|
||||||
|
Out4: 'out4',
|
||||||
|
Out5: 'out5',
|
||||||
|
Out6: 'out6',
|
||||||
|
Out7: 'out7',
|
||||||
|
Out8: 'out8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_STAGES: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sidebar: SidebarId | 'input';
|
||||||
|
}[] = [
|
||||||
|
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
|
||||||
|
{ key: 'gate', label: 'GATE', sidebar: 'gate' },
|
||||||
|
{ key: 'geq', label: 'GEQ', sidebar: 'geq' },
|
||||||
|
{ key: 'peq', label: 'PEQ', sidebar: 'input' },
|
||||||
|
{ key: 'hplp', label: 'HP/LP', sidebar: 'input' },
|
||||||
|
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OUTPUT_STAGES: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sidebar: SidebarId | 'output';
|
||||||
|
}[] = [
|
||||||
|
{ key: 'xover', label: 'XOVER', sidebar: 'output' },
|
||||||
|
{ key: 'peq', label: 'PEQ', sidebar: 'output' },
|
||||||
|
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
|
||||||
|
{ key: 'compressor', label: 'COMP', sidebar: 'compressor' },
|
||||||
|
{ key: 'limiter', label: 'LIMIT', sidebar: 'limiter' },
|
||||||
|
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
|
||||||
|
{ key: 'mute', label: 'MUTE', sidebar: 'output' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INPUT_COLORS = [
|
||||||
|
'var(--accent-a, #60a5fa)',
|
||||||
|
'var(--accent-b, #34d399)',
|
||||||
|
'var(--accent-c, #f59e0b)',
|
||||||
|
'var(--accent-d, #f472b6)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const W = 1800;
|
||||||
|
const H = 520;
|
||||||
|
const MARGIN = 36;
|
||||||
|
const LEFT_CHAIN_W = 560;
|
||||||
|
const RIGHT_CHAIN_W = 620;
|
||||||
|
|
||||||
|
// Width of the invisible "hitbox" stroke drawn under each visible line, in SVG
|
||||||
|
// user units. Much wider than the visible 1–2.5px line so it's easy to click
|
||||||
|
// or catch with a scroll wheel without needing pixel-perfect aim.
|
||||||
|
const LINE_HITBOX_WIDTH = 14;
|
||||||
|
|
||||||
|
// How long the gain tooltip stays visible after the last wheel tick, in ms.
|
||||||
|
const GAIN_TOOLTIP_HOLD_MS = 700;
|
||||||
|
|
||||||
|
function getRoutes(dsp: DSP408, output: OutputChannel) {
|
||||||
|
return dsp.state.current_config.output_states[output].matrix_routes;
|
||||||
|
}
|
||||||
|
function isRouted(dsp: DSP408, output: OutputChannel, input: InputChannel) {
|
||||||
|
return getRoutes(dsp, output).connected.includes(input);
|
||||||
|
}
|
||||||
|
function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
|
||||||
|
return getRoutes(dsp, output).gains[input];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGainDb(value: number) {
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNonPassiveWheel(
|
||||||
|
ref: React.RefObject<Element | null>,
|
||||||
|
handler: (e: WheelEvent) => void
|
||||||
|
) {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
handlerRef.current = handler;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const listener = (e: Event) => handlerRef.current(e as WheelEvent);
|
||||||
|
el.addEventListener('wheel', listener, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', listener);
|
||||||
|
}, [ref]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GainTooltipState = {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SignalFlowMatrix({
|
||||||
|
dsp,
|
||||||
|
setMatrixRouting,
|
||||||
|
setMatrixGain,
|
||||||
|
setActiveSidebar,
|
||||||
|
onGainPreview,
|
||||||
|
}: {
|
||||||
|
dsp: DSP408;
|
||||||
|
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
|
||||||
|
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
|
||||||
|
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
|
||||||
|
onGainPreview: (input: InputChannel, output: OutputChannel, value: number | null) => void;
|
||||||
|
}) {
|
||||||
|
const nInputs = INPUT_ORDER.length;
|
||||||
|
const nOutputs = OUTPUT_ORDER.length;
|
||||||
|
|
||||||
|
const usableH = H - MARGIN * 2;
|
||||||
|
const inputRowH = usableH / nInputs;
|
||||||
|
const outputRowH = usableH / nOutputs;
|
||||||
|
|
||||||
|
const inputStageW = LEFT_CHAIN_W / (INPUT_STAGES.length + 1);
|
||||||
|
const outputStageW = RIGHT_CHAIN_W / (OUTPUT_STAGES.length + 1);
|
||||||
|
|
||||||
|
const leftStart = MARGIN;
|
||||||
|
const rightStart = W - MARGIN - RIGHT_CHAIN_W;
|
||||||
|
|
||||||
|
const inputCenterY = (i: number) => MARGIN + inputRowH * (i + 0.5);
|
||||||
|
const outputCenterY = (j: number) => MARGIN + outputRowH * (j + 0.5);
|
||||||
|
|
||||||
|
const inputNodeX = leftStart + LEFT_CHAIN_W;
|
||||||
|
const outputNodeX = rightStart;
|
||||||
|
|
||||||
|
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
label: '',
|
||||||
|
});
|
||||||
|
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const WHEEL_COMMIT_DELAY = 250;
|
||||||
|
|
||||||
|
// key = `${input}-${output}`
|
||||||
|
const pendingGains = useRef<Map<string, number>>(new Map());
|
||||||
|
const commitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
|
||||||
|
commitTimers.current.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
||||||
|
const routes = getRoutes(dsp, output);
|
||||||
|
const next = routes.connected.includes(input)
|
||||||
|
? routes.connected.filter((c) => c !== input)
|
||||||
|
: [...routes.connected, input];
|
||||||
|
void setMatrixRouting(output, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
const handleWheelGain = (e: WheelEvent, output: OutputChannel, input: InputChannel) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const key = `${input}-${output}`;
|
||||||
|
// Base on the pending value if we're mid-wheel, otherwise the real state
|
||||||
|
const current = pendingGains.current.get(key) ?? getGain(dsp, output, input);
|
||||||
|
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
|
||||||
|
const next =
|
||||||
|
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
|
||||||
|
|
||||||
|
pendingGains.current.set(key, next);
|
||||||
|
onGainPreview(input, output, next);
|
||||||
|
|
||||||
|
// Debounced send: only the final value goes out once the wheel pauses
|
||||||
|
const existing = commitTimers.current.get(key);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
commitTimers.current.set(
|
||||||
|
key,
|
||||||
|
setTimeout(async () => {
|
||||||
|
commitTimers.current.delete(key);
|
||||||
|
const value = pendingGains.current.get(key);
|
||||||
|
if (value === undefined) return;
|
||||||
|
await setMatrixGain(input, output, value);
|
||||||
|
// Only release if the user hasn't wheeled again while we were awaiting
|
||||||
|
if (pendingGains.current.get(key) === value) {
|
||||||
|
pendingGains.current.delete(key);
|
||||||
|
onGainPreview(input, output, null);
|
||||||
|
}
|
||||||
|
}, WHEEL_COMMIT_DELAY)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tooltip (unchanged, shows the pending value)
|
||||||
|
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
|
||||||
|
setGainTooltip({ visible: true, x: e.clientX, y: e.clientY, label: formatGainDb(next) });
|
||||||
|
hideTooltipTimer.current = setTimeout(() => {
|
||||||
|
setGainTooltip((t) => ({ ...t, visible: false }));
|
||||||
|
}, GAIN_TOOLTIP_HOLD_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
useNonPassiveWheel(svgRef, (e) => {
|
||||||
|
const hit = (e.target as Element).closest<SVGElement>('[data-wheel-route]');
|
||||||
|
if (!hit) return; // not over a connected line: let the page scroll normally
|
||||||
|
const { input, output } = hit.dataset;
|
||||||
|
if (!input || !output) return;
|
||||||
|
handleWheelGain(e, output as OutputChannel, input as InputChannel);
|
||||||
|
});
|
||||||
|
|
||||||
|
const routePairs = useMemo(() => {
|
||||||
|
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
|
||||||
|
OUTPUT_ORDER.forEach((output, j) => {
|
||||||
|
INPUT_ORDER.forEach((input, i) => pairs.push({ input, output, i, j }));
|
||||||
|
});
|
||||||
|
return pairs;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draw dashed (unconnected) links first so connected (solid) links always
|
||||||
|
// paint on top of them, regardless of input/output index order. This has
|
||||||
|
// to be recomputed whenever routing changes, since isRouted reads live
|
||||||
|
// dsp state — unlike routePairs above, which is just static index pairs.
|
||||||
|
const orderedRoutePairs = useMemo(() => {
|
||||||
|
return [...routePairs].sort((a, b) => {
|
||||||
|
const aConnected = isRouted(dsp, a.output, a.input) ? 1 : 0;
|
||||||
|
const bConnected = isRouted(dsp, b.output, b.input) ? 1 : 0;
|
||||||
|
return aConnected - bConnected;
|
||||||
|
});
|
||||||
|
}, [routePairs, dsp]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signal-flow">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
className="signal-flow__svg"
|
||||||
|
>
|
||||||
|
{orderedRoutePairs.map(({ input, output, i, j }) => {
|
||||||
|
const connected = isRouted(dsp, output, input);
|
||||||
|
const y1 = inputCenterY(i);
|
||||||
|
const y2 = outputCenterY(j);
|
||||||
|
const cx1 = inputNodeX + (outputNodeX - inputNodeX) * 0.4;
|
||||||
|
const cx2 = inputNodeX + (outputNodeX - inputNodeX) * 0.6;
|
||||||
|
const path = `M ${inputNodeX} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${outputNodeX} ${y2}`;
|
||||||
|
const color = INPUT_COLORS[i % INPUT_COLORS.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${input}-${output}`}>
|
||||||
|
{/* Invisible wide-stroke hitbox: carries the click/wheel handlers so
|
||||||
|
the line is easy to grab without needing to hit the thin visible
|
||||||
|
stroke exactly. Sits directly under the visible path. */}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth={LINE_HITBOX_WIDTH}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleToggle(output, input)}
|
||||||
|
data-wheel-route={connected ? '' : undefined}
|
||||||
|
data-input={input}
|
||||||
|
data-output={output}
|
||||||
|
/>
|
||||||
|
{/* Visible line, purely decorative — no pointer events, so it never
|
||||||
|
competes with the hitbox above for hover/click/wheel. */}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={connected ? color : 'var(--border-hairline, #333)'}
|
||||||
|
strokeWidth={connected ? 2.5 : 1}
|
||||||
|
strokeDasharray={connected ? undefined : '4 5'}
|
||||||
|
opacity={connected ? 0.9 : 0.35}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{INPUT_ORDER.map((input, i) => {
|
||||||
|
const y = inputCenterY(i);
|
||||||
|
const boxH = Math.min(inputRowH * 0.55, 40);
|
||||||
|
const name = dsp.state.current_config.input_states[input].name;
|
||||||
|
const boxes = [{ key: 'name', label: name, sidebar: 'input' as const }, ...INPUT_STAGES];
|
||||||
|
return (
|
||||||
|
<g key={input}>
|
||||||
|
{boxes.map((stage, si) => {
|
||||||
|
const x = leftStart + si * inputStageW;
|
||||||
|
const sidebarId = stage.sidebar === 'input' ? INPUT_SIDEBAR[input] : stage.sidebar;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={stage.key}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={x + 3}
|
||||||
|
y={y - boxH / 2}
|
||||||
|
width={inputStageW - 6}
|
||||||
|
height={boxH}
|
||||||
|
rx={6}
|
||||||
|
className="signal-flow__stage"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + inputStageW / 2}
|
||||||
|
y={y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="signal-flow__stage-label"
|
||||||
|
>
|
||||||
|
{si === 0 ? name : stage.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{OUTPUT_ORDER.map((output, j) => {
|
||||||
|
const y = outputCenterY(j);
|
||||||
|
const boxH = Math.min(outputRowH * 0.55, 32);
|
||||||
|
const name = dsp.state.current_config.output_states[output].name;
|
||||||
|
const boxes = [
|
||||||
|
...OUTPUT_STAGES,
|
||||||
|
{ key: 'name', label: name, sidebar: 'output' as const },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<g key={output}>
|
||||||
|
{boxes.map((stage, si) => {
|
||||||
|
const x = rightStart + si * outputStageW;
|
||||||
|
const sidebarId =
|
||||||
|
stage.sidebar === 'output' ? OUTPUT_SIDEBAR[output] : stage.sidebar;
|
||||||
|
const isLast = si === boxes.length - 1;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={stage.key}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={x + 3}
|
||||||
|
y={y - boxH / 2}
|
||||||
|
width={outputStageW - 6}
|
||||||
|
height={boxH}
|
||||||
|
rx={6}
|
||||||
|
className="signal-flow__stage"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + outputStageW / 2}
|
||||||
|
y={y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="signal-flow__stage-label"
|
||||||
|
>
|
||||||
|
{isLast ? name : stage.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{gainTooltip.visible && (
|
||||||
|
<div
|
||||||
|
className="signal-flow__gain-tooltip"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: gainTooltip.x + 14,
|
||||||
|
top: gainTooltip.y - 28,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 50,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
background: 'var(--surface-elevated, rgba(20, 20, 24, 0.92))',
|
||||||
|
color: 'var(--text-primary, #fff)',
|
||||||
|
border: '1px solid var(--border-hairline, #333)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gainTooltip.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignalFlowMatrix;
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--font-md);
|
font-size: var(--font-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
@@ -150,6 +150,9 @@
|
|||||||
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
||||||
var(--tab-close-size)
|
var(--tab-close-size)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
@@ -204,7 +207,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: var(--tab-close-font);
|
font-size: var(--tab-close-font);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,4 +264,4 @@
|
|||||||
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
||||||
var(--tab-close-size)
|
var(--tab-close-size)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { DSP, AppPage } from '../../types/types';
|
import { DSP, AppPage, isConnected } from '../../types';
|
||||||
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
|
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
|
||||||
import { PlusIcon } from '../../assets/icons/PlusIcon';
|
import { PlusIcon } from '../../assets/icons/PlusIcon';
|
||||||
import { CrownIcon } from '../../assets/icons/CrownIcon';
|
import { CrownIcon } from '../../assets/icons/CrownIcon';
|
||||||
@@ -24,6 +24,7 @@ function Tab({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
dsp: DSP;
|
dsp: DSP;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -33,8 +34,9 @@ function Tab({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onConnect: (dsp: DSP) => void;
|
onConnect: (dsp: DSP) => void;
|
||||||
onDisconnect: (dsp: DSP) => void;
|
onDisconnect: (dsp: DSP) => void;
|
||||||
|
actions?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const isConnected = dsp.status === 'connected';
|
const isDspConnected = isConnected(dsp);
|
||||||
|
|
||||||
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -45,11 +47,7 @@ function Tab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
|
||||||
'tab',
|
|
||||||
active && 'tab--active',
|
|
||||||
dimmed && 'tab--dimmed',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
data-step={step}
|
data-step={step}
|
||||||
@@ -62,28 +60,36 @@ function Tab({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tab-led"
|
className="tab-led"
|
||||||
aria-label={
|
aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
|
||||||
isConnected
|
|
||||||
? `Disconnect ${dsp.name}`
|
|
||||||
: `Connect ${dsp.name}`
|
|
||||||
}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (isConnected) {
|
onSelect();
|
||||||
|
|
||||||
|
if (isDspConnected) {
|
||||||
onDisconnect(dsp);
|
onDisconnect(dsp);
|
||||||
} else {
|
} else {
|
||||||
onConnect(dsp);
|
onConnect(dsp);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Led status={dsp.status} />
|
<Led
|
||||||
|
status={
|
||||||
|
dsp.isConnecting
|
||||||
|
? 'connecting'
|
||||||
|
: isConnected(dsp)
|
||||||
|
? 'connected'
|
||||||
|
: 'disconnected'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="tab-name" title={dsp.name}>
|
<span className="tab-name" title={dsp.name}>
|
||||||
{dsp.name}
|
{dsp.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tab-close"
|
className="tab-close"
|
||||||
@@ -1,55 +1,66 @@
|
|||||||
.gauge-viewport {
|
.gauge-viewport {
|
||||||
container-type: size;
|
|
||||||
container-name: gauge;
|
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: auto;
|
width: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge-container {
|
|
||||||
--s: min(calc(100cqw / 70), calc(100cqh / 240));
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
|
|
||||||
width: calc(70 * var(--s));
|
|
||||||
height: calc(240 * var(--s));
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
transform: translateX(-50%);
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gauge-track {
|
.gauge-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
width: calc(70 * var(--s));
|
min-height: 0;
|
||||||
height: calc(210 * var(--s));
|
width: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 1.2cqh;
|
||||||
|
|
||||||
|
padding-bottom: 1.5cqh;
|
||||||
|
padding-top: 1.2cqh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-track.no-ticks {
|
||||||
|
padding-left: calc(2.75cqh);
|
||||||
|
padding-right: calc(2.75cqh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin-bottom: calc(8 * var(--s));
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks span {
|
||||||
|
width: 2.8cqh;
|
||||||
|
height: 0.55cqh;
|
||||||
|
min-height: 1px;
|
||||||
|
|
||||||
|
background: var(--border-hairline);
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
width: calc(24 * var(--s));
|
width: 6.5cqh;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.track {
|
.track {
|
||||||
@@ -59,56 +70,35 @@
|
|||||||
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
width: calc(2 * var(--s));
|
width: 0.55cqh;
|
||||||
|
min-width: 1px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
background: var(--border-hairline);
|
background: var(--border-hairline);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
width: calc(18 * var(--s));
|
width: 5cqh;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
|
||||||
transform: translate(-50%, 50%);
|
transform: translate(-50%, 50%);
|
||||||
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: var(--border-width-active) solid var(--accent-brand);
|
border: 0.55cqh solid var(--accent-brand);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks span {
|
|
||||||
width: calc(10 * var(--s));
|
|
||||||
height: calc(2 * var(--s));
|
|
||||||
|
|
||||||
background: var(--border-hairline);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left { left: calc(8 * var(--s)); }
|
|
||||||
.right { right: calc(8 * var(--s)); }
|
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
margin-top: calc(4 * var(--s));
|
margin-top: 0.8cqh;
|
||||||
padding: calc(2 * var(--s)) calc(4 * var(--s));
|
padding: 0.5cqh 1cqh;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
@@ -120,16 +110,21 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
color: var(--accent-brand);
|
color: var(--accent-brand);
|
||||||
font-size: var(--font-xxs);
|
font-size: 3.2cqh;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: 1.6cqh;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.value.no-ticks {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.value input {
|
.value input {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -138,8 +133,8 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
border: 0.3cqh solid var(--border-hairline);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 1.2cqh;
|
||||||
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
@@ -150,4 +145,27 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gauge-viewport.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-viewport.is-disabled .slider {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-viewport.is-disabled .thumb {
|
||||||
|
border-color: var(--border-hairline);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-viewport.is-disabled .value {
|
||||||
|
color: var(--border-hairline);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import './VerticalSlider.css';
|
||||||
|
|
||||||
|
type StepFn = (value: number) => number;
|
||||||
|
type QuantizeFn = (value: number) => number;
|
||||||
|
type Scale = 'linear' | 'log';
|
||||||
|
|
||||||
|
type VerticalSliderProps = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
|
||||||
|
step?: StepFn;
|
||||||
|
quantize?: QuantizeFn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional discrete value set (must be sorted ascending). When provided,
|
||||||
|
* this takes full precedence over `step`/`quantize`/`scale`: the slider
|
||||||
|
* only ever lands on entries from this array. `min`/`max` are used to
|
||||||
|
* slice out the applicable sub-range of `values` (e.g. per-filter-type
|
||||||
|
* Q ranges that all share one master Q table) — both bounds should be
|
||||||
|
* exact members of `values`. Thumb position and drag/keyboard/wheel
|
||||||
|
* navigation are index-based rather than value-based, since spacing in
|
||||||
|
* the array need not be uniform or log-uniform.
|
||||||
|
*/
|
||||||
|
values?: readonly number[];
|
||||||
|
|
||||||
|
value: number;
|
||||||
|
onChange?: (value: number) => Promise<boolean>;
|
||||||
|
onInput?: (value: number) => void;
|
||||||
|
|
||||||
|
unit?: string | ((value: number) => string);
|
||||||
|
formatValue?: (value: number, decimals: number) => string;
|
||||||
|
|
||||||
|
showTicks?: boolean;
|
||||||
|
|
||||||
|
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
|
||||||
|
scale?: Scale;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
/** Greys out the slider and blocks all interaction. */
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStep: StepFn = () => 0.1;
|
||||||
|
|
||||||
|
function formatValueDefault(v: number, decimals: number) {
|
||||||
|
return v.toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_TICKS = 3;
|
||||||
|
const MAX_TICK_SPACING_PX = 16;
|
||||||
|
|
||||||
|
function getDecimals(n: number) {
|
||||||
|
const s = n.toString();
|
||||||
|
const i = s.indexOf('.');
|
||||||
|
return i === -1 ? 0 : s.length - i - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scale helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
/** Guards against log(0) / log(negative); falls back to a tiny positive epsilon. */
|
||||||
|
function safeLogBound(n: number) {
|
||||||
|
return n > 0 ? n : Number.EPSILON;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
|
||||||
|
function closestIndex(arr: readonly number[], v: number) {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = arr.length - 1;
|
||||||
|
if (v <= arr[0]) return 0;
|
||||||
|
if (v >= arr[hi]) return hi;
|
||||||
|
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (arr[mid] === v) return mid;
|
||||||
|
if (arr[mid] < v) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = arr[lo - 1];
|
||||||
|
const b = arr[lo];
|
||||||
|
return v - a <= b - v ? lo - 1 : lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a real value to a 0..1 position, honoring the chosen scale (or index position within `list`). */
|
||||||
|
function valueToPercent(
|
||||||
|
v: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
scale: Scale,
|
||||||
|
list: readonly number[] | null
|
||||||
|
) {
|
||||||
|
if (list && list.length > 1) {
|
||||||
|
return closestIndex(list, v) / (list.length - 1);
|
||||||
|
}
|
||||||
|
if (scale === 'log') {
|
||||||
|
const logMin = Math.log(safeLogBound(min));
|
||||||
|
const logMax = Math.log(safeLogBound(max));
|
||||||
|
if (logMax === logMin) return 0;
|
||||||
|
const logV = Math.log(safeLogBound(v));
|
||||||
|
return (logV - logMin) / (logMax - logMin);
|
||||||
|
}
|
||||||
|
if (max === min) return 0;
|
||||||
|
return (v - min) / (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a 0..1 position back to a real value, honoring the chosen scale (or nearest index within `list`). */
|
||||||
|
function percentToValue(
|
||||||
|
percent: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
scale: Scale,
|
||||||
|
list: readonly number[] | null
|
||||||
|
) {
|
||||||
|
if (list && list.length > 1) {
|
||||||
|
const idx = Math.round(percent * (list.length - 1));
|
||||||
|
return list[Math.min(list.length - 1, Math.max(0, idx))];
|
||||||
|
}
|
||||||
|
if (scale === 'log') {
|
||||||
|
const logMin = Math.log(safeLogBound(min));
|
||||||
|
const logMax = Math.log(safeLogBound(max));
|
||||||
|
return Math.exp(logMin + percent * (logMax - logMin));
|
||||||
|
}
|
||||||
|
return min + percent * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a StepFn that yields roughly `points` evenly-spaced steps between
|
||||||
|
* `min` and `max` on a logarithmic scale — e.g. makeLogStep(19.7, 20160, 300)
|
||||||
|
* gives ~300 points across a 20 Hz - 20 kHz range, with step size growing
|
||||||
|
* proportionally to the current value (fine resolution at low frequencies,
|
||||||
|
* coarser at high ones — matches how the ear perceives frequency).
|
||||||
|
*
|
||||||
|
* This is an approximation used to size keyboard/wheel nudges — it does NOT
|
||||||
|
* guarantee landing on the same exact grid a real device would produce.
|
||||||
|
* Pair it with `quantize` (see makeLogQuantize) when you need the slider to
|
||||||
|
* always land exactly on the device's own value set.
|
||||||
|
*/
|
||||||
|
export function makeLogStep(min: number, max: number, points: number): StepFn {
|
||||||
|
const ratio = Math.pow(max / min, 1 / Math.max(1, points));
|
||||||
|
const stepSize = ratio - 1;
|
||||||
|
return (value: number) => Math.max(min, value) * stepSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a QuantizeFn that snaps to the exact same log-spaced grid a device
|
||||||
|
* using a `raw` 0..points index would produce, e.g. for a frequency raw
|
||||||
|
* value stored as:
|
||||||
|
* raw = round(log(Hz / min) / log(max / min) * points)
|
||||||
|
* Hz = min * (max / min) ** (raw / points)
|
||||||
|
* makeLogQuantize(19.7, 20160, 300) reproduces exactly that 301-point set
|
||||||
|
* (raw 0..300), so the slider only ever settles on values the device itself
|
||||||
|
* would encode/decode to — no drift from an approximate step size.
|
||||||
|
*/
|
||||||
|
export function makeLogQuantize(min: number, max: number, points: number): QuantizeFn {
|
||||||
|
const logRatio = Math.log(max / min);
|
||||||
|
return (value: number) => {
|
||||||
|
const v = Math.max(min, Math.min(max, value));
|
||||||
|
const raw = Math.round((Math.log(v / min) / logRatio) * points);
|
||||||
|
const clampedRaw = Math.min(points, Math.max(0, raw));
|
||||||
|
return min * Math.pow(max / min, clampedRaw / points);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerticalSlider({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = defaultStep,
|
||||||
|
quantize,
|
||||||
|
values,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onInput,
|
||||||
|
unit = '',
|
||||||
|
formatValue,
|
||||||
|
showTicks = true,
|
||||||
|
scale = 'linear',
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: VerticalSliderProps) {
|
||||||
|
const [editingText, setEditingText] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const keyboardValue = useRef<number | null>(null);
|
||||||
|
const [displayValue, setDisplayValue] = useState(value);
|
||||||
|
const dragValue = useRef(value);
|
||||||
|
const dragging = useRef(false);
|
||||||
|
const thumbRef = useRef<HTMLDivElement>(null);
|
||||||
|
const keyboardEditing = useRef(false);
|
||||||
|
const wheelValue = useRef<number | null>(null);
|
||||||
|
const wheelEditing = useRef(false);
|
||||||
|
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [tickCount, setTickCount] = useState(MIN_TICKS);
|
||||||
|
|
||||||
|
if (scale === 'log' && !values && (min <= 0 || max <= 0)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice the discrete value set down to the applicable [min, max] sub-range.
|
||||||
|
// Falls back to the full array if the slice is empty (e.g. misconfigured bounds).
|
||||||
|
const listValues = useMemo(() => {
|
||||||
|
if (!values || values.length === 0) return null;
|
||||||
|
const filtered = values.filter((v) => v >= min && v <= max);
|
||||||
|
return filtered.length > 0 ? filtered : values;
|
||||||
|
}, [values, min, max]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
dragging.current ||
|
||||||
|
keyboardEditing.current ||
|
||||||
|
wheelEditing.current ||
|
||||||
|
editingText !== null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
|
const recompute = () => {
|
||||||
|
const h = el.clientHeight;
|
||||||
|
if (!h) return;
|
||||||
|
|
||||||
|
const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
|
||||||
|
|
||||||
|
setTickCount(n);
|
||||||
|
};
|
||||||
|
recompute();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(recompute);
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) return;
|
||||||
|
|
||||||
|
dragging.current = false;
|
||||||
|
keyboardEditing.current = false;
|
||||||
|
keyboardValue.current = null;
|
||||||
|
wheelEditing.current = false;
|
||||||
|
wheelValue.current = null;
|
||||||
|
if (wheelCommitTimeout.current) {
|
||||||
|
clearTimeout(wheelCommitTimeout.current);
|
||||||
|
wheelCommitTimeout.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingText(null);
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
onInput?.(value);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const decimals = useMemo(() => {
|
||||||
|
if (listValues) return Math.min(3, getDecimals(displayValue));
|
||||||
|
return Math.min(3, getDecimals(step(displayValue)));
|
||||||
|
}, [listValues, step, displayValue]);
|
||||||
|
|
||||||
|
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
|
||||||
|
|
||||||
|
const displayUnit = typeof unit === 'function' ? unit(displayValue) : unit;
|
||||||
|
|
||||||
|
function normalizeValue(input: number) {
|
||||||
|
const clamped = Math.min(max, Math.max(min, input));
|
||||||
|
|
||||||
|
if (listValues) {
|
||||||
|
return listValues[closestIndex(listValues, clamped)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantize) {
|
||||||
|
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = step(clamped);
|
||||||
|
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
|
||||||
|
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValue(input: number) {
|
||||||
|
const next = normalizeValue(input);
|
||||||
|
dragValue.current = next;
|
||||||
|
setDisplayValue(next);
|
||||||
|
onInput?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardEditing.current = true;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Base off displayValue (our own last-known-good local state), not the
|
||||||
|
// `value` prop: if onChange's round trip through the parent/audio engine
|
||||||
|
// hasn't landed yet, `value` can still be stale, which made repeated
|
||||||
|
// key presses appear to snap back to the pre-press position.
|
||||||
|
const base = keyboardValue.current ?? displayValue;
|
||||||
|
|
||||||
|
let next: number;
|
||||||
|
if (listValues) {
|
||||||
|
const idx = closestIndex(listValues, base);
|
||||||
|
let nextIdx = idx;
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowRight')
|
||||||
|
nextIdx = Math.min(listValues.length - 1, idx + 1);
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') nextIdx = Math.max(0, idx - 1);
|
||||||
|
next = listValues[nextIdx];
|
||||||
|
} else {
|
||||||
|
const currentStep = step(base);
|
||||||
|
next = base;
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
|
||||||
|
next = normalizeValue(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardValue.current = next;
|
||||||
|
updateValue(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeyUp() {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (keyboardValue.current == null) return;
|
||||||
|
|
||||||
|
const next = keyboardValue.current;
|
||||||
|
const success = await onChange?.(next);
|
||||||
|
|
||||||
|
keyboardEditing.current = false;
|
||||||
|
keyboardValue.current = null;
|
||||||
|
|
||||||
|
if (success === false) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
onInput?.(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
wheelEditing.current = true;
|
||||||
|
|
||||||
|
const base = wheelValue.current ?? displayValue;
|
||||||
|
// deltaY < 0 means scrolling up/away from the user — treat that as
|
||||||
|
// increasing the value, matching the vertical slider's "up = more".
|
||||||
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
|
|
||||||
|
let next: number;
|
||||||
|
if (listValues) {
|
||||||
|
const idx = closestIndex(listValues, base);
|
||||||
|
const nextIdx = Math.min(listValues.length - 1, Math.max(0, idx + direction));
|
||||||
|
next = listValues[nextIdx];
|
||||||
|
} else {
|
||||||
|
const currentStep = step(base);
|
||||||
|
next = normalizeValue(base + direction * currentStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
wheelValue.current = next;
|
||||||
|
updateValue(next);
|
||||||
|
|
||||||
|
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
|
||||||
|
wheelCommitTimeout.current = setTimeout(() => {
|
||||||
|
void commitWheel();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitWheel() {
|
||||||
|
if (wheelValue.current == null) return;
|
||||||
|
|
||||||
|
const next = wheelValue.current;
|
||||||
|
const success = await onChange?.(next);
|
||||||
|
|
||||||
|
wheelEditing.current = false;
|
||||||
|
wheelValue.current = null;
|
||||||
|
|
||||||
|
if (success === false) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
onInput?.(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sliderRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// React attaches onWheel passively by default, so preventDefault()
|
||||||
|
// wouldn't stop the page from scrolling underneath the slider — attach
|
||||||
|
// natively with { passive: false } instead.
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function valueFromPointer(clientY: number) {
|
||||||
|
if (!sliderRef.current) return;
|
||||||
|
const rect = sliderRef.current.getBoundingClientRect();
|
||||||
|
const percent = 1 - (clientY - rect.top) / rect.height;
|
||||||
|
const raw = percentToValue(percent, min, max, scale, listValues);
|
||||||
|
updateValue(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const target = e.currentTarget;
|
||||||
|
dragging.current = true;
|
||||||
|
target.setPointerCapture(e.pointerId);
|
||||||
|
valueFromPointer(e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
valueFromPointer(e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const pointerId = e.pointerId;
|
||||||
|
const finalValue = dragValue.current;
|
||||||
|
|
||||||
|
const success = await onChange?.(finalValue);
|
||||||
|
|
||||||
|
dragging.current = false;
|
||||||
|
|
||||||
|
if (success === false) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
onInput?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.hasPointerCapture(pointerId)) {
|
||||||
|
target.releasePointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitEditing() {
|
||||||
|
if (disabled) {
|
||||||
|
setEditingText(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingText !== null && editingText !== '' && editingText !== '-') {
|
||||||
|
const parsed = Number(editingText);
|
||||||
|
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
const next = normalizeValue(parsed);
|
||||||
|
|
||||||
|
setDisplayValue(next);
|
||||||
|
onInput?.(next);
|
||||||
|
|
||||||
|
const success = await onChange?.(next);
|
||||||
|
|
||||||
|
if (success === false) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
dragValue.current = value;
|
||||||
|
onInput?.(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingText(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={['gauge-viewport', disabled && 'is-disabled', className].filter(Boolean).join(' ')}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
|
||||||
|
ref={trackRef}
|
||||||
|
>
|
||||||
|
{showTicks && (
|
||||||
|
<div className="ticks left">
|
||||||
|
{Array.from({ length: tickCount }).map((_, i) => (
|
||||||
|
<span key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="slider"
|
||||||
|
ref={sliderRef}
|
||||||
|
role="slider"
|
||||||
|
aria-disabled={disabled}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={displayValue}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
<div className="track" />
|
||||||
|
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTicks && (
|
||||||
|
<div className="ticks right">
|
||||||
|
{Array.from({ length: tickCount }).map((_, i) => (
|
||||||
|
<span key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={['value', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
disabled={disabled}
|
||||||
|
value={editingText ?? formattedValue}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
onBlur={commitEditing}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') inputRef.current?.blur();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingText(null);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{displayUnit && <span className="value-unit">{' ' + displayUnit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
.gain-panel-viewport {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 90 / 400;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gain-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform-origin: center center;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-sizing: border-box;
|
|
||||||
gap: var(--space-2);
|
|
||||||
background: var(--bg-void);
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gain-panel__title {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container */
|
|
||||||
|
|
||||||
.gain-section {
|
|
||||||
container-type: size;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
height: var(--dsp-control-heigth);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
background: var(--bg-panel);
|
|
||||||
|
|
||||||
z-index: 20;
|
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gain-panels {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- graph strip, sits above the gain section ---
|
|
||||||
Requires the shared parent (whatever renders <GainSection />) to be
|
|
||||||
position: relative — it already must be, for .gain-section's absolute
|
|
||||||
bottom:0 to anchor correctly. */
|
|
||||||
.linear-graph-section {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: var(--dsp-control-heigth);
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: var(--space-4);
|
|
||||||
|
|
||||||
background: var(--bg-panel);
|
|
||||||
z-index: 10; /* stays below .gain-section's z-index: 20 */
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import VerticalSlider from '../Slider/VerticalSlider';
|
|
||||||
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
|
|
||||||
import { DSP } from '../../../types/types';
|
|
||||||
import Button from '../../Button/Button';
|
|
||||||
import ChannelGroup from '../ChannelGroup/ChannelGroup';
|
|
||||||
import { LinearGraph } from '../LinearGraph/LinearGraph';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import './GainSection.css';
|
|
||||||
|
|
||||||
const PANEL_DESIGN_WIDTH = 90;
|
|
||||||
const PANEL_DESIGN_HEIGHT = 400;
|
|
||||||
|
|
||||||
function GainPanel({
|
|
||||||
dsp,
|
|
||||||
channel,
|
|
||||||
title,
|
|
||||||
setGain,
|
|
||||||
setMute,
|
|
||||||
setInverse,
|
|
||||||
}: {
|
|
||||||
dsp: DSP;
|
|
||||||
channel: Channel;
|
|
||||||
title: string;
|
|
||||||
setGain: (channel: Channel, gain: number) => Promise<boolean>;
|
|
||||||
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
|
||||||
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
|
|
||||||
}) {
|
|
||||||
const channelState =
|
|
||||||
'Input' in channel
|
|
||||||
? dsp.state?.current_config.input_states[channel.Input]
|
|
||||||
: dsp.state?.current_config.output_states[channel.Output];
|
|
||||||
|
|
||||||
const muted = channelState?.mute ?? false;
|
|
||||||
const inverted = channelState?.phase_inverted ?? false;
|
|
||||||
const gain = channelState?.gain ?? 0;
|
|
||||||
|
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [scale, setScale] = useState(1);
|
|
||||||
|
|
||||||
const toggleMute = async () => {
|
|
||||||
await setMute(channel, !muted);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleInverse = async () => {
|
|
||||||
await setInverse(channel, !inverted);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = viewportRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
|
||||||
const { width, height } = entries[0].contentRect;
|
|
||||||
if (width === 0 || height === 0) return;
|
|
||||||
setScale(Math.min(width / PANEL_DESIGN_WIDTH, height / PANEL_DESIGN_HEIGHT));
|
|
||||||
});
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="gain-panel-viewport" ref={viewportRef}>
|
|
||||||
<div
|
|
||||||
className="gain-panel"
|
|
||||||
style={{
|
|
||||||
width: PANEL_DESIGN_WIDTH,
|
|
||||||
height: PANEL_DESIGN_HEIGHT,
|
|
||||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="gain-panel__title">{title}</div>
|
|
||||||
|
|
||||||
<VerticalSlider
|
|
||||||
min={-60}
|
|
||||||
max={12}
|
|
||||||
step={0.5}
|
|
||||||
switchStepValue={-10}
|
|
||||||
switchStep={0.1}
|
|
||||||
unit=" dB"
|
|
||||||
value={gain}
|
|
||||||
onChange={(value) => setGain(channel, value)}
|
|
||||||
style={{ width: 70, height: 250, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute} style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}>
|
|
||||||
{muted ? 'Muted' : 'Mute '}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="toggle"
|
|
||||||
className={inverted ? 'active-warning' : ''}
|
|
||||||
onClick={toggleInverse}
|
|
||||||
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
|
|
||||||
>
|
|
||||||
{inverted ? 'Inverted' : 'Invert'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GainSection({
|
|
||||||
dsp,
|
|
||||||
setGain,
|
|
||||||
setMute,
|
|
||||||
setInverse,
|
|
||||||
}: {
|
|
||||||
dsp: DSP;
|
|
||||||
setGain: (channel: Channel, gain: number) => Promise<boolean>;
|
|
||||||
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
|
|
||||||
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
|
|
||||||
}) {
|
|
||||||
const inputs: { channel: Channel; title: string }[] = [
|
|
||||||
{ channel: { Input: InputChannel.InA }, title: 'In A' },
|
|
||||||
{ channel: { Input: InputChannel.InB }, title: 'In B' },
|
|
||||||
{ channel: { Input: InputChannel.InC }, title: 'In C' },
|
|
||||||
{ channel: { Input: InputChannel.InD }, title: 'In D' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const outputs: { channel: Channel; title: string }[] = [
|
|
||||||
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' },
|
|
||||||
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
|
|
||||||
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
|
|
||||||
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
|
|
||||||
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
|
|
||||||
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
|
|
||||||
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
|
|
||||||
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="linear-graph-section">
|
|
||||||
<LinearGraph
|
|
||||||
xMin={20}
|
|
||||||
xMax={20000}
|
|
||||||
yMin={-24}
|
|
||||||
yMax={24}
|
|
||||||
yStep={6}
|
|
||||||
xLabel="Hz"
|
|
||||||
yLabel="dB"
|
|
||||||
draw={({ ctx, toPx }) => {
|
|
||||||
// placeholder flat 0 dB response — swap for real EQ/filter data.
|
|
||||||
// NOTE: this maps x linearly; for a real frequency response you'll
|
|
||||||
// likely want a log-frequency x-axis instead (see chat notes).
|
|
||||||
ctx.strokeStyle = '#4ade80';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
const [x0, y0] = toPx(20, 0);
|
|
||||||
const [x1, y1] = toPx(20000, 0);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x0, y0);
|
|
||||||
ctx.lineTo(x1, y1);
|
|
||||||
ctx.stroke();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="gain-section">
|
|
||||||
<div className="gain-panels">
|
|
||||||
<ChannelGroup title="Input">
|
|
||||||
{inputs.map(({ channel, title }) => (
|
|
||||||
<GainPanel
|
|
||||||
key={title}
|
|
||||||
dsp={dsp}
|
|
||||||
channel={channel}
|
|
||||||
title={title}
|
|
||||||
setGain={setGain}
|
|
||||||
setMute={setMute}
|
|
||||||
setInverse={setInverse}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ChannelGroup>
|
|
||||||
|
|
||||||
<ChannelGroup title="Output">
|
|
||||||
{outputs.map(({ channel, title }) => (
|
|
||||||
<GainPanel
|
|
||||||
key={title}
|
|
||||||
dsp={dsp}
|
|
||||||
channel={channel}
|
|
||||||
title={title}
|
|
||||||
setGain={setGain}
|
|
||||||
setMute={setMute}
|
|
||||||
setInverse={setInverse}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ChannelGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GainSection;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
.rerere {
|
|
||||||
height: 100%;
|
|
||||||
width: 10%;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import VerticalSlider from '../Slider/VerticalSlider';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
|
|
||||||
import { DSP } from '../../../types/types';
|
|
||||||
import { NotificationType } from '../../../types/types';
|
|
||||||
|
|
||||||
import ChannelGroup from '../ChannelGroup/ChannelGroup';
|
|
||||||
import './GateSection.css';
|
|
||||||
|
|
||||||
function GateSection({
|
|
||||||
dsp,
|
|
||||||
}: {
|
|
||||||
dsp: DSP;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rerere">
|
|
||||||
<VerticalSlider
|
|
||||||
min={-60}
|
|
||||||
max={12}
|
|
||||||
step={0.5}
|
|
||||||
switchStepValue={-10}
|
|
||||||
switchStep={0.1}
|
|
||||||
unit=" dB"
|
|
||||||
value={0}
|
|
||||||
style={{ width: 70, height: 250, flex: '0 0 auto' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GateSection;
|
|
||||||