diff --git a/.gitignore b/.gitignore
index 2b914f3..01a2362 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/node_modules
config.py
config.js
-*.pyc
\ No newline at end of file
+*.pyc
+.ruff_cache/
\ No newline at end of file
diff --git a/README.md b/README.md
index 625ed61..a3bf340 100644
--- a/README.md
+++ b/README.md
@@ -30,19 +30,22 @@ We know that there are already some people using IDEs like Pulsar or Vim with Fo
- the websocket package for Python (install it with `pip install websockets`)
## β¨ Features
-- a code editor with syntax highlighting
-- a beautiful and responsive interface with resizable panel and console logs
-- change font family, code font size and interface font size
-- change the code theme with dozens of themes available
-- a console log to see the output of FoxDot with the last item appended at the top
-- a panel to see all relevant information (Bpm, Scale, Root, Cpu usage, Timer, the current beat modulo, a list of active players and a list of available Loops, Synths, and Fx)
-- show the list of active players with their id, name of synth or loop, time of activity (with color based on elapsed time).
-- the ability to stop a player by clicking on the list.
-- show the solo players with a different color
-- a piano roll based on the actual scale and root and the ability to insert note in the editor by clicking on it (`Alt-P` or click on `Root` to enable/disable the piano roll)
-- a auto-completion feature with the ability to insert new random player, list of synth, list of fx, list of loop and a lot more.
-- comment and stop/start a player with just `Alt-x`
-- and a lot more to come...
+- Code editor with Python syntax highlighting
+- Responsive interface with resizable panel and console logs
+- Customizable font family, code font size and interface font size
+- Dozens of code themes and interface themes available
+- Console log with color-coded output (errors in red, prompts in green, input in blue)
+- Info panel showing BPM, Scale, Root, CPU usage, Timer, beat modulo, active players, and available Loops/Synths/Fx
+- Active player list with synth name, duration (color transitions from green to orange to red), and click-to-stop
+- Solo players highlighted
+- Piano roll based on current scale and root with click-to-insert notes (`Alt-P` or click `Root`)
+- Auto-completion with synth/fx descriptions shown in the dropdown
+- Line markers for visual annotation (Alt+1/2/3 for Red/Green/Blue, Alt+4 to reset)
+- Comment and stop/start a player with `Alt-x`
+- WebSocket auto-reconnect on connection loss
+- WebSocket origin check to prevent cross-origin code injection
+- Unified startup file supporting both FoxDot and Renardo
+- Auto-detection of Python executable (python3/python)
## π Installation with Python
@@ -61,15 +64,19 @@ nano config.py
Change the FOXDOT_PATH to your foxdot path
```
-### Copy the content of this startup.py file to your FoxDot startup file:
+### Copy `startup.py` and `synth_definitions.py` to your FoxDot startup directory:
```text
-Your startup file is located in the FoxDot directory:
-/FoxDot/lib/Custom/startup.py
+Your startup directory is located in the FoxDot directory:
+/FoxDot/lib/Custom/
+
+Copy both files:
+- startup.py
+- synth_definitions.py (required for autocomplete descriptions)
```
### Start SuperCollider as usual
-### Run Foxdot with a server:
+### Run FoxDot with a server:
```python
python server.py
```
@@ -132,10 +139,14 @@ nano config.js
Change the FOXDOT_PATH to your foxdot path. Not yet fully compatible with Renardo, you have to change the spawn in `server.js` to the correct command.
```
-### Copy the content of this startup.py file to your FoxDot startup file:
+### Copy `startup.py` and `synth_definitions.py` to your FoxDot startup directory:
```text
-Your startup file is located in the FoxDot directory:
-/FoxDot/lib/Custom/startup.py
+Your startup directory is located in the FoxDot directory:
+/FoxDot/lib/Custom/
+
+Copy both files:
+- startup.py
+- synth_definitions.py (required for autocomplete descriptions)
```
### Start SuperCollider as usual
@@ -171,39 +182,68 @@ Inject the websocket package dependency in the Renardo code:
pipx inject renardo websockets
```
-Copy the content of startup-renardo.py to your Renardo startup file:
+Copy `startup.py` and `synth_definitions.py` to your Renardo startup directory (the same `startup.py` now supports both FoxDot and Renardo):
```bash
This should be located in the Renardo directory, something like:
-~/.local/pipx/venvs/renardo/lib/python3.13/site-packages/renardo_lib/Custom/startup.py
+~/.local/pipx/venvs/renardo/lib/python3.13/site-packages/renardo_lib/Custom/
+
+Copy both files:
+- startup.py
+- synth_definitions.py (required for autocomplete descriptions)
```
## π Usage
All things that work in FoxDot or Renardo will work in WebFoxDot.
-- evaluate a line of code with `Ctrl-Enter`
-- evaluate a block of code with `Ctrl-Alt-Enter`
-- stop all players with `Ctrl-;`
-- auto-completion with `Ctrl-Space`
-- comment a line and stop a player with `Alt-x`
-- you can comment and stop a block with `Ctrl-Alt-x`
-- show the piano roll with `Alt-P`
-- solo a player with `Alt-S`
-- unsolo all players with `Ctrl-Alt-S`
-- increment a value with `Alt-=`
-- decrement a value with `Ctrl-Alt-=`
+| Shortcut | Action |
+| --------------------------- | ---------------------------------- |
+| `Ctrl-Enter` | Evaluate line |
+| `Ctrl-Alt-Enter` | Evaluate block |
+| `Ctrl-;` | Stop all players |
+| `Ctrl-Space` | Auto-completion |
+| `Alt-X` | Comment line and stop player |
+| `Ctrl-Alt-X` | Comment block and stop players |
+| `Alt-P` | Toggle piano roll |
+| `Alt-S` | Solo player |
+| `Ctrl-Alt-S` | Unsolo all players |
+| `Alt-=` | Increment value under cursor |
+| `Ctrl-Alt-=` | Decrement value under cursor |
+| `Alt-1` / `Alt-2` / `Alt-3` | Set Red / Green / Blue line marker |
+| `Alt-4` | Reset all line markers |
+| `Alt-F` | Open search |
+| `Ctrl-G` | Find next |
+| `Ctrl-S` | Save code to file |
+
+A full list of shortcuts is available in the config panel.
+
+## π§Ή Linting
+
+**Python** (requires [ruff](https://docs.astral.sh/ruff/)):
+```bash
+ruff check .
+ruff format .
+```
-And many more to discover, a full list of shortcuts is available in the config panel.
+**JavaScript** (requires npm dependencies):
+```bash
+npm install
+npx eslint src/js/
+```
## πΊοΈ Roadmap
- [ ] Add more interface themes
- [x] Add a vim mode
-- [ ] a clear console button
-- [X] renardo compatibility
+- [x] Clear console button
+- [x] Renardo compatibility (unified startup.py)
+- [x] Error detection and colored log output
+- [x] Auto-detection of python executable
+- [x] Synth/Fx descriptions in autocomplete
+- [x] Line markers (Alt+1/2/3/4)
## π License
-Copyright Β© 2025 [CrashServer](https://github.com/CrashServer).
+Copyright Β© 2025-2026 [CrashServer](https://github.com/CrashServer).
This project is MIT licensed.
diff --git a/TODO.md b/TODO.md
index a8b29f5..881904b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,15 +5,15 @@
- [X] autocomplete
# log
-- [ ] fix better output (on error and color)
+- [X] fix better output (on error and color) - errors detected via pattern matching, stderr captured
## Server
-- [ ] python vs python3
+- [X] python vs python3 (auto-detected via `resolve_python()`)
## Autocomplete
- [X] fix automatic retrieve
- [X] fix end of line with .
-- [ ] add definition
+- [X] add definition (synth/fx descriptions in autocomplete tooltips)
## editor
- [X] fix python syntax highlight
\ No newline at end of file
diff --git a/config.py.sample b/config.py.sample
index 7a56ae9..d85eed0 100644
--- a/config.py.sample
+++ b/config.py.sample
@@ -1,21 +1,23 @@
# Configuration file when running the server with python server.py
-#
-# IMPORTANT: Please uncomment the PROGRAM_PATH AND PROGRAM_CMD lines that corresponds to the program you are using and fill the correct path when using a Github clone of FoxDot.
-# Also make sure to set the correct python executable (python3 or python).
#
-# 1. I'm using FoxDot installed with pip
+# IMPORTANT: Uncomment the PROGRAM_PATH and PROGRAM_CMD that matches your setup.
+# The python executable is auto-detected (python3 or python), so you don't need
+# to worry about which one to use.
+#
+# 1. FoxDot installed with pip
#
# PROGRAM_PATH = "/"
# PROGRAM_CMD = ['FoxDot', '-p']
#
#
-# 2. I'm using the FoxDot library cloned from GitHub
+# 2. FoxDot cloned from GitHub (python is auto-resolved at runtime)
#
+# from server import resolve_python
# PROGRAM_PATH = "/my/path/to/FoxDot"
-# PROGRAM_CMD = ['python', '-m', 'FoxDot', '-p']
+# PROGRAM_CMD = [resolve_python(), '-m', 'FoxDot', '-p']
#
#
-# 3. I'm using Renardo installed with pipx
+# 3. Renardo installed with pipx
#
# PROGRAM_PATH = "/"
-# PROGRAM_CMD = ["renardo", '-p']
\ No newline at end of file
+# PROGRAM_CMD = ["renardo", '-p']
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..bd6ba36
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,20 @@
+import js from "@eslint/js";
+import globals from "globals";
+
+export default [
+ js.configs.recommended,
+ {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ CodeMirror: "readonly",
+ },
+ },
+ rules: {
+ "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
+ eqeqeq: "error",
+ "no-var": "error",
+ "prefer-const": "warn",
+ },
+ },
+];
diff --git a/index.html b/index.html
index 57e2781..3378cdf 100644
--- a/index.html
+++ b/index.html
@@ -96,6 +96,7 @@
diff --git a/package-lock.json b/package-lock.json
index 36083cf..95ea399 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,9 @@
"ws": "^8.18.0"
},
"devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "eslint": "^10.2.1",
+ "globals": "^17.5.0",
"vite": "^6.0.5"
}
},
@@ -440,6 +443,200 @@
"node": ">=18"
}
},
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
+ "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
+ "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.34.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.2.tgz",
@@ -706,6 +903,13 @@
"win32"
]
},
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -713,12 +917,122 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/codemirror": {
"version": "5.65.18",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.18.tgz",
"integrity": "sha512-Gaz4gHnkbHMGgahNt3CA5HBk5lLQBqmD/pBgeB4kQU6OedZmqMBjlRF0LSrp2tJ4wlLNPm2FfaUd1pDy0mdlpA==",
"license": "MIT"
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
@@ -760,6 +1074,250 @@
"@esbuild/win32-x64": "0.24.2"
}
},
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
+ "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.5.5",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.4",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-scope/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -775,6 +1333,166 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
+ "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -794,6 +1512,83 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -830,6 +1625,26 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/rollup": {
"version": "4.34.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.2.tgz",
@@ -869,6 +1684,29 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -879,6 +1717,29 @@
"node": ">=0.10.0"
}
},
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
"node_modules/vite": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
@@ -951,6 +1812,32 @@
}
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
@@ -971,6 +1858,19 @@
"optional": true
}
}
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
}
}
}
diff --git a/package.json b/package.json
index 6489046..bbff3ec 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,9 @@
"preview": "vite preview"
},
"devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "eslint": "^10.2.1",
+ "globals": "^17.5.0",
"vite": "^6.0.5"
},
"dependencies": {
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..3173da4
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,3 @@
+[lint]
+# F821: undefined names are FoxDot/Renardo runtime globals injected at startup
+per-file-ignores = { "startup.py" = ["F821"] }
diff --git a/server.py b/server.py
index 85f220d..f8a93f2 100644
--- a/server.py
+++ b/server.py
@@ -1,93 +1,152 @@
-import json
import asyncio
-import websockets
-from subprocess import Popen, PIPE
+import json
import os
+import shutil
+import sys
+from subprocess import PIPE, Popen
+
+import websockets
try:
- from config import PROGRAM_PATH, PROGRAM_CMD
-except Exception as e:
- print("Make sure to uncomment the correct PROGRAM_PATH and PROGRAM_CMD in config.py")
-
+ from config import PROGRAM_CMD, PROGRAM_PATH
+except Exception:
+ print(
+ "Make sure to uncomment the correct PROGRAM_PATH and PROGRAM_CMD in config.py"
+ )
+
+
+def resolve_python():
+ """Find the correct python executable (python3 or python)."""
+ for name in ("python3", "python"):
+ if shutil.which(name):
+ return name
+ print("No python executable found in PATH")
+ sys.exit(1)
+
+
+HOST = "localhost"
+WS_PORT = 1234
+ALLOWED_ORIGINS = {"http://localhost", "http://127.0.0.1"}
+
+
+_ERROR_PATTERNS = (
+ "Traceback",
+ "Error",
+ "Exception",
+ "SyntaxError",
+ "NameError",
+ "TypeError",
+ "ValueError",
+ "AttributeError",
+ "KeyError",
+ "IndexError",
+ "ZeroDivisionError",
+)
+
+
+def detect_log_color(message):
+ if any(p in message for p in _ERROR_PATTERNS):
+ return "error"
+ if message.startswith(">>>"):
+ return "prompt"
+ if message.startswith(">>"):
+ return "input"
+ return None
+
async def broadcast_log(message, clients):
+ color = detect_log_color(message)
+ payload = json.dumps({"type": "foxdot_log", "data": message, "color": color})
for client in clients:
- await client.send(json.dumps({"type": "foxdot_log", "data": message, "color": None}))
+ await client.send(payload)
+
+
+def check_origin(origin):
+ if origin is None:
+ return True # non-browser clients (e.g. Python websocket)
+ return any(origin.startswith(allowed) for allowed in ALLOWED_ORIGINS)
+
+
+async def handle_websocket(websocket, _path, foxdot_process, clients):
+ origin = websocket.request_headers.get("Origin")
+ if not check_origin(origin):
+ print(f"Rejected connection from origin: {origin}")
+ await websocket.close(4003, "Forbidden origin")
+ return
-# Handle WebSocket connections
-async def handle_websocket(websocket, path, foxdot_process, clients):
print("New client connected")
clients.add(websocket)
try:
async for message in websocket:
try:
data = json.loads(message)
- if data["type"] == "evaluate_code":
- code = data["code"]
- await broadcast_log(f"{code}", clients)
- foxdot_process.stdin.write(f"{code}\n\n".encode())
- foxdot_process.stdin.flush()
except json.JSONDecodeError:
- # Ignore non-JSON messages
- pass
+ continue
+
+ if data["type"] == "evaluate_code":
+ code = data["code"]
+ await broadcast_log(code, clients)
+ foxdot_process.stdin.write(f"{code}\n\n".encode())
+ foxdot_process.stdin.flush()
finally:
clients.remove(websocket)
print("Client disconnected")
+
+async def _read_stream(stream, clients, is_stderr=False):
+ buffer = []
+ while True:
+ line = await asyncio.get_event_loop().run_in_executor(None, stream.readline)
+ if not line:
+ continue
+
+ log_message = line.decode()
+ if "^" not in log_message or not log_message.replace("^", "").isspace():
+ log_message = log_message.strip()
+ print(log_message)
+
+ buffer.append(log_message)
+ if (not log_message or log_message.endswith((">>>", "..."))) and buffer:
+ await broadcast_log("\n".join(buffer), clients)
+ buffer = []
+
+
+async def read_foxdot_output(foxdot_process, clients):
+ await asyncio.gather(
+ _read_stream(foxdot_process.stdout, clients),
+ _read_stream(foxdot_process.stderr, clients, is_stderr=True),
+ )
+
+
async def main():
- # Start FoxDot
- try:
+ try:
foxdot_process = Popen(
PROGRAM_CMD,
cwd=PROGRAM_PATH,
stdin=PIPE,
- stdout=PIPE,
- stderr=PIPE,
- env={**os.environ, "PYTHONUNBUFFERED": "1"}
+ stdout=PIPE,
+ stderr=PIPE,
+ env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
-
print(f"FoxDot started, pid: {foxdot_process.pid}")
except Exception as e:
print(f"Error starting FoxDot: {e}")
- print("##### IMPORTANT #####")
- print("Make sure FoxDot or Renardo is correctly installed and the path is set in config.py")
-
+ print(
+ "Make sure FoxDot or Renardo is correctly installed and the path is set in config.py"
+ )
return
- # Set of connected clients
clients = set()
-
- # Function to handle FoxDot output
- async def handle_foxdot_output():
- buffer = []
- while True:
- line = await asyncio.get_event_loop().run_in_executor(
- None,
- foxdot_process.stdout.readline
- )
- if line:
- log_message = line.decode()
- if '^' not in log_message or not log_message.replace('^', '').isspace():
- log_message = line.decode().strip()
- print(log_message)
-
- buffer.append(log_message)
- if not log_message or log_message.endswith((">>>", "...")):
- if buffer:
- await broadcast_log("\n".join(buffer), clients)
- buffer = []
-
- # start the task to handle FoxDot output in the background
- asyncio.create_task(handle_foxdot_output())
-
- # Start WebSocket server
+ _output_task = asyncio.create_task(read_foxdot_output(foxdot_process, clients))
+
async with websockets.serve(
lambda ws, path: handle_websocket(ws, path, foxdot_process, clients),
- "localhost",
- 1234
+ HOST,
+ WS_PORT,
):
- print("WebSocket server started on port 1234")
- await asyncio.Future() # run forever
+ print(f"WebSocket server started on port {WS_PORT}")
+ await asyncio.Future()
+
if __name__ == "__main__":
- asyncio.run(main())
\ No newline at end of file
+ asyncio.run(main())
diff --git a/src/css/style.css b/src/css/style.css
index 5b0a1a0..7b32994 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -124,12 +124,10 @@ body {
#logPanel {
display: flex;
flex-direction: column;
+ position: relative;
margin: 0;
width: 100%;
- /* flex-grow: 1; */
height: 20%;
- /* padding-top: 30px; */
-
}
#logAndChat {
@@ -151,6 +149,25 @@ body {
margin: 0 0.1rem;
}
+#clearLogsBtn {
+ position: absolute;
+ right: 8px;
+ bottom: 8px;
+ background: var(--bg-col-2);
+ color: var(--text-col-1);
+ border: 1px solid var(--border-col-1);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px 6px;
+ opacity: 0.6;
+ z-index: 10;
+}
+
+#clearLogsBtn:hover {
+ opacity: 1;
+}
+
.log-entry {
margin: 4px 2px;
padding: 2px 5px;
@@ -164,6 +181,16 @@ body {
color: var(--col-full-red);
}
+.log-entry.prompt-log {
+ border-color: var(--col-green);
+ color: var(--col-green);
+}
+
+.log-entry.input-log {
+ border-color: var(--col-blue);
+ color: var(--text-col-1);
+}
+
.log-entry.help {
border-color: var(--col-blue);
}
@@ -267,4 +294,15 @@ body {
}
.CodeMirror-hint-active {
background: var(--col-blue);
+}
+
+.hint-name {
+ font-weight: bold;
+}
+
+.hint-description {
+ margin-left: 8px;
+ opacity: 0.6;
+ font-size: 0.9em;
+ font-style: italic;
}
\ No newline at end of file
diff --git a/src/js/configPanel.js b/src/js/configPanel.js
index ac684c3..82d0442 100644
--- a/src/js/configPanel.js
+++ b/src/js/configPanel.js
@@ -113,21 +113,21 @@ export function setupConfigPanel(editor) {
});
// Open the shortcut modal
- modalbtn.onclick = function() {
+ modalbtn.addEventListener('click', () => {
modal.style.display = "block";
- }
+ });
// Close the shortcut modal
- closeModal.onclick = function() {
+ closeModal.addEventListener('click', () => {
modal.style.display = "none";
- }
+ });
// Close the modal when clicking outside
- window.onclick = function(event) {
- if (event.target == modal) {
- modal.style.display = "none";
+ window.addEventListener('click', (event) => {
+ if (event.target === modal) {
+ modal.style.display = "none";
}
- }
+ });
// Theme change
// themeInterface.addEventListener('change', (event) => {
diff --git a/src/js/foxdotAutocomplete.js b/src/js/foxdotAutocomplete.js
index f04ad86..1fd9800 100644
--- a/src/js/foxdotAutocomplete.js
+++ b/src/js/foxdotAutocomplete.js
@@ -148,6 +148,27 @@ export const foxdotAutocomplete = {
{ text: 'drive=', displayText: 'drive' },
],
+ withRender: function(items) {
+ const self = this;
+ return items.map(item => item.description
+ ? { ...item, render: (el, data, cur) => self.renderHint(el, data, cur) }
+ : item
+ );
+ },
+
+ renderHint: function(element, _data, cur) {
+ const name = document.createElement('span');
+ name.className = 'hint-name';
+ name.textContent = cur.displayText;
+ element.appendChild(name);
+ if (cur.description) {
+ const desc = document.createElement('span');
+ desc.className = 'hint-description';
+ desc.textContent = cur.description;
+ element.appendChild(desc);
+ }
+ },
+
hint: function(cm, CodeMirror) {
const cursor = cm.getCursor();
const token = cm.getTokenAt(cursor);
@@ -200,13 +221,12 @@ export const foxdotAutocomplete = {
else if (isInsideParentheses) {
const prefix = token.string.slice(0, cursorPosition - token.start).replace(/[^a-zA-Z:]/g, "");
- let foxdotKeyword = [];
+ let foxdotKeyword;
if (prefix.startsWith('x')) {
- foxdotKeyword = this.fxList.filter(f => f.displayText.toLowerCase().startsWith(prefix.slice(1,).toLowerCase()));;
- }
- else {
+ foxdotKeyword = this.withRender(this.fxList.filter(f => f.displayText.toLowerCase().startsWith(prefix.slice(1).toLowerCase())));
+ } else {
const combinedKeyword = [...this.foxKeyword, ...this.patternFunction];
- foxdotKeyword = combinedKeyword.filter(f => f.displayText.toLowerCase().startsWith(prefix.toLowerCase()));;
+ foxdotKeyword = combinedKeyword.filter(f => f.displayText.toLowerCase().startsWith(prefix.toLowerCase()));
}
return {
list: foxdotKeyword.length > 0 ? foxdotKeyword.sort((a, b) => a.displayText.localeCompare(b.displayText)) : foxdotKeyword.sort((a, b) => a.displayText.localeCompare(b.displayText)),
@@ -224,7 +244,7 @@ export const foxdotAutocomplete = {
};
}
else if (matchPlayer) {
- const filteredSynths = this.synths.filter(synth => synth.displayText.includes(token.string));
+ const filteredSynths = this.withRender(this.synths.filter(synth => synth.displayText.includes(token.string)));
const synthMatch = line.match(/>>\s*([a-zA-Z0-9_]+)\(/);
if (synthMatch) {
const synthWithoutUndescore = filteredSynths.filter(synth => !synth.displayText.endsWith("_"));
@@ -236,7 +256,7 @@ export const foxdotAutocomplete = {
} else {
return {
list: filteredSynths.length > 0 ? filteredSynths.sort((a, b) => a.displayText.localeCompare(b.displayText)) : this.synths.sort((a, b) => a.displayText.localeCompare(b.displayText)),
- from: CodeMirror.Pos(cursor.line, (token.string.trim() == "") ? token.start +1 : token.start ),
+ from: CodeMirror.Pos(cursor.line, (token.string.trim() === "") ? token.start + 1 : token.start),
to: cursor,
};
}
diff --git a/src/js/functionUtils.js b/src/js/functionUtils.js
index d37996d..916e2b5 100644
--- a/src/js/functionUtils.js
+++ b/src/js/functionUtils.js
@@ -1,48 +1,6 @@
export const functionUtils = {
playersList: [],
- stopClock(wsServer) {
- wsServer.send(JSON.stringify({
- type: 'evaluate_code',
- code: 'Clock.clear()\n'
- }));
- },
-
- unSoloPlayers(wsServer) {
- wsServer.send(JSON.stringify({
- type: 'evaluate_code',
- code: 'unsolo()\n'
- }));
- },
-
- soloPlayer(cm, wsServer) {
- const cursor = cm.getCursor();
- let startLine = cursor.line;
- let endLine = cursor.line;
-
- const blockCode = cm.getRange(
- {line: startLine, ch: 0},
- {line: endLine, ch: cm.getLine(endLine).length}
- );
-
- const playerName = this.getPlayer(blockCode);
-
- if (playerName) {
- wsServer.send(JSON.stringify({
- type: 'evaluate_code',
- code: `${playerName}.solo()\n`
- }));
- }
- },
-
- // reset the chrono
- resetChrono(wsServer) {
- wsServer.send(JSON.stringify({
- type: 'evaluate_code',
- code: 'wsPanel.timeInit = time()\n'
- }));
- },
-
// Check if the code to evaluate is a player and if it is, stop it
ifPlayerStop(codeToEvaluate) {
const playerPattern = /^[#_]\s*([a-zA-Z]\d+|[a-zA-Z]{2})\s*>>|^#\s*[a-zA-Z]\d+\s*\.\w+\s*=\s*\d+/;
@@ -82,7 +40,7 @@ export const functionUtils = {
if (blockCode.trim()) {
// Check if the code is a player and if it is, stop it
- let blockCodeArray = blockCode.split('\n');
+ const blockCodeArray = blockCode.split('\n');
blockCodeArray.forEach((code, index) => {
blockCodeArray[index] = functionUtils.ifPlayerStop(code);
});
@@ -94,8 +52,8 @@ export const functionUtils = {
// Save the content of the editor into a .py file
- saveEditorContent(cm, wsServer) {
- let content = cm.getValue();
+ saveEditorContent(cm) {
+ const content = cm.getValue();
const timestamp = new Date().toISOString().replace(/[:.-]/g, '');
const filename = `code_${timestamp}.py`;
@@ -129,9 +87,10 @@ export const functionUtils = {
// Increment/decrement the value of the number under the cursor
incrementValue(cm, value){
const cursor = cm.getCursor();
- let text = cm.getRange({line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch +1});
- if (parseInt(text) || parseInt(text) === 0) {
- cm.replaceRange((parseInt(text) + value).toString(), {line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch +1});
+ const text = cm.getRange({line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch +1});
+ const num = parseInt(text);
+ if (!isNaN(num)) {
+ cm.replaceRange((num + value).toString(), {line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch + 1});
cm.setCursor({line: cursor.line, ch: cursor.ch});
}
},
diff --git a/src/js/logs.js b/src/js/logs.js
index 92ce883..23cb28c 100644
--- a/src/js/logs.js
+++ b/src/js/logs.js
@@ -6,7 +6,7 @@ export const logsUtils = {
logs: document.getElementById('logs'),
initResize: function(editor) {
- this.logSeparator.addEventListener('mousedown', (e) => {
+ this.logSeparator.addEventListener('mousedown', () => {
this.isResizing = true;
document.addEventListener('mousemove', this.resize.bind(this, editor));
document.addEventListener('mouseup', this.stopResize.bind(this));
@@ -31,35 +31,35 @@ export const logsUtils = {
document.removeEventListener('mouseup', this.stopResize);
},
- // Show logs in the console
appendLog(message, color) {
- if (message === '') {
- return;
- }
+ if (message === '') return;
+
const entry = document.createElement('pre');
entry.className = 'log-entry';
- if (color){
- entry.style.color = color;
- }
- if (message.includes('Traceback')) {
+
+ if (color === 'error' || message.includes('Traceback')) {
message = this.formatErrorMessage(message);
entry.classList.add('error-log');
- }
- else if (!message.includes('>>')) {
+ } else if (color === 'prompt') {
+ entry.classList.add('prompt-log');
+ } else if (color === 'input') {
+ entry.classList.add('input-log');
+ } else if (!message.includes('>>')) {
entry.classList.add('help');
}
+
entry.textContent = message;
- this.logs.insertBefore(entry, logs.firstChild);
+ this.logs.insertBefore(entry, this.logs.firstChild);
this.logs.scrollTop = 0;
},
+ clear() {
+ this.logs.innerHTML = '';
+ },
+
formatErrorMessage(errorMessage) {
const lines = errorMessage.split('\n');
const caretIndex = lines.findIndex(line => line.includes('File "FoxDot", line 1'));
- if (caretIndex) {
- return lines.slice(caretIndex + 1).join('\n');
- } else {
- return errorMessage;
- }
+ return caretIndex !== -1 ? lines.slice(caretIndex + 1).join('\n') : errorMessage;
}
};
\ No newline at end of file
diff --git a/src/js/main.js b/src/js/main.js
index 1dfc117..966bf6a 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -32,9 +32,29 @@ document.addEventListener('DOMContentLoaded', async () => {
// DOM elements
const chrono = document.getElementById('timer');
- // WebSocket
- const wsServer = new WebSocket(`ws://localhost:1234`);
- let foxdotWs = null;
+ // WebSocket with auto-reconnect
+ let wsServer = null;
+
+ function connectWsServer() {
+ wsServer = new WebSocket(`ws://localhost:1234`);
+ wsServer.onopen = () => console.log('Backend WebSocket connected');
+ wsServer.onclose = () => {
+ console.log('Backend WebSocket closed, reconnecting in 3s...');
+ setTimeout(connectWsServer, 3000);
+ };
+ wsServer.onerror = () => wsServer.close();
+ wsServer.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ if (message.type === 'foxdot_log') {
+ logsUtils.appendLog(message.data, message.color);
+ }
+ } catch (error) {
+ console.error('Error parsing WebSocket message:', error);
+ }
+ };
+ }
+ connectWsServer();
// CodeMirror
const editor = CodeMirror(document.getElementById('editor'), {
@@ -67,38 +87,44 @@ document.addEventListener('DOMContentLoaded', async () => {
// Init the logs panel
logsUtils.initResize(editor);
+ document.getElementById('clearLogsBtn').addEventListener('click', () => logsUtils.clear());
+
+ // Line markers for visual annotation
+ const activeMarkers = [];
+
+ function setMarker(cm, color) {
+ const cursor = cm.getCursor();
+ const line = cursor.line;
+ const className = `line${color}`;
+ const existing = activeMarkers.findIndex(m => m.line === line);
+ if (existing !== -1) {
+ activeMarkers[existing].handle.clear();
+ activeMarkers.splice(existing, 1);
+ }
+ const handle = cm.addLineClass(line, 'background', className);
+ activeMarkers.push({ line, handle, color: className });
+ }
- EventEmitter.on('send_foxdot', (command) => {
- wsServer.send(JSON.stringify({
- type: 'evaluate_code',
- code: command
- }));
- });
-
- // Log the FoxDot output
- wsServer.onmessage = (event) => {
- try {
- const message = JSON.parse(event.data);
- if (message.type === 'foxdot_log') {
- // console.log(message.data);
- logsUtils.appendLog(message.data, message.color);
- }
- } catch (error) {
+ function resetMarkers(cm) {
+ activeMarkers.forEach(m => cm.removeLineClass(m.handle, 'background', m.color));
+ activeMarkers.length = 0;
+ }
+
+ function sendToServer(code) {
+ if (wsServer && wsServer.readyState === WebSocket.OPEN) {
+ wsServer.send(JSON.stringify({ type: 'evaluate_code', code }));
}
- };
+ }
+
+ EventEmitter.on('send_foxdot', sendToServer);
// Reset timer on click
- chrono.addEventListener('click', ()=> functionUtils.resetChrono(wsServer));
+ chrono.addEventListener('click', () => sendToServer('ws_panel.time_init = time()'));
// Evaluate the code and highlight the block with a flash
function evaluateCode(cm, multi){
- var [blockCode, startLine, endLine] = functionUtils.getCodeAndCheckStop(cm, multi);
-
- // Send code
- wsServer.send(JSON.stringify({
- type: 'evaluate_code',
- code: blockCode,
- }));
+ const [blockCode, startLine, endLine] = functionUtils.getCodeAndCheckStop(cm, multi);
+ sendToServer(blockCode);
// Highlight the code
for (let i = startLine; i <= endLine; i++) {
@@ -112,9 +138,9 @@ document.addEventListener('DOMContentLoaded', async () => {
}
editor.setOption('extraKeys', {
- 'Ctrl-;': ()=> functionUtils.stopClock(wsServer),
+ 'Ctrl-;': () => sendToServer('Clock.clear()'),
'Ctrl-Space': 'autocomplete',
- 'Ctrl-S': (cm)=> {functionUtils.saveEditorContent(cm,wsServer)},
+ 'Ctrl-S': (cm) => functionUtils.saveEditorContent(cm),
'Alt-X': (cm) => {
cm.toggleComment();
evaluateCode(cm, false);
@@ -125,12 +151,18 @@ document.addEventListener('DOMContentLoaded', async () => {
cm.toggleComment();
evaluateCode(cm, true);
},
- 'Alt-S': (cm) => {functionUtils.soloPlayer(cm, wsServer)},
- 'Ctrl-Alt-S': () => {functionUtils.unSoloPlayers(wsServer)},
- 'Alt-1': (cm) => markerUtils.setMarker(cm, "Red"),
- 'Alt-2': (cm) => markerUtils.setMarker(cm, "Green"),
- 'Alt-3': (cm) => markerUtils.setMarker(cm, "Blue"),
- 'Alt-4': () => markerUtils.resetMarkers(),
+ 'Alt-S': (cm) => {
+ const player = functionUtils.getPlayer(cm.getRange(
+ {line: cm.getCursor().line, ch: 0},
+ {line: cm.getCursor().line, ch: cm.getLine(cm.getCursor().line).length}
+ ));
+ if (player) sendToServer(`${player}.solo()`);
+ },
+ 'Ctrl-Alt-S': () => sendToServer('unsolo()'),
+ 'Alt-1': (cm) => setMarker(cm, "Red"),
+ 'Alt-2': (cm) => setMarker(cm, "Green"),
+ 'Alt-3': (cm) => setMarker(cm, "Blue"),
+ 'Alt-4': (cm) => resetMarkers(cm),
'Ctrl-Enter': (cm) => {evaluateCode(cm, false)},
'Ctrl-Alt-Enter': (cm) => {evaluateCode(cm, true)},
'Alt-F': "findPersistent",
@@ -149,8 +181,8 @@ document.addEventListener('DOMContentLoaded', async () => {
hint: (cm) => foxdotAutocomplete.hint(cm, CodeMirror),
});
- function foxDotWs(){
- foxdotWs = new WebSocket(`ws://localhost:20000`);
+ function connectFoxDotWs(){
+ const foxdotWs = new WebSocket(`ws://localhost:20000`);
foxdotWs.onopen = () => {
foxdotWs.send(JSON.stringify({ type: 'get_autocomplete' }));
};
@@ -161,11 +193,11 @@ document.addEventListener('DOMContentLoaded', async () => {
const { loops, fxList, synthList } = functionUtils.formatFoxDotAutocomplete(message);
foxdotAutocomplete.loopList = loops;
foxdotAutocomplete.fxList = fxList;
- foxdotAutocomplete.synths= synthList;
+ foxdotAutocomplete.synths = synthList;
updateHelpPanel(loops, fxList, synthList);
- if (loops.length == 0 || fxList.length == 0 || synthList.length == 0) {
+ if (loops.length === 0 || fxList.length === 0 || synthList.length === 0) {
console.error(`Error on retrieving loops name (${loops.length}), effets (${fxList.length}), synths (${synthList.length})`);
}
}
@@ -173,13 +205,14 @@ document.addEventListener('DOMContentLoaded', async () => {
console.error('Error on FoxDot message ', error);
}
};
- foxdotWs.onerror = (err) => {
- console.error('Socket encountered error: ', err.message, 'Closing socket');
- foxdotWs.close();
+ foxdotWs.onclose = () => {
+ console.log('FoxDot WebSocket closed, reconnecting in 3s...');
+ setTimeout(connectFoxDotWs, 3000);
};
+ foxdotWs.onerror = () => foxdotWs.close();
}
- foxDotWs();
+ connectFoxDotWs();
// piano insert at cursor
document.querySelectorAll('#piano-roll .piano-key li').forEach(key => {
diff --git a/src/js/panel.js b/src/js/panel.js
index b14ec36..76400d3 100644
--- a/src/js/panel.js
+++ b/src/js/panel.js
@@ -1,5 +1,5 @@
import { EventEmitter } from './eventBus.js';
-import { playersList, updatePlayersList } from './functionUtils.js';
+import { updatePlayersList } from './functionUtils.js';
// Toggle Panel
const panel = document.getElementById('panel')
@@ -37,56 +37,64 @@ panelToggle.addEventListener('change', () => {
}
})
-const ws = new WebSocket(`ws://localhost:20000`);
+let ws = null;
-ws.onopen = function() {
- console.log('FoxDot WebSocket connection opened');
-};
+function connectPanelWs() {
+ ws = new WebSocket(`ws://localhost:20000`);
-ws.onmessage = function(event) {
- const data = JSON.parse(event.data);
-
- switch(data.type) {
- case 'scale':
- document.getElementById('scale').textContent = data.scale;
- updatePianoKeys(data.scale, document.getElementById('root').textContent);
- break;
- case 'root':
- document.getElementById('root').textContent = data.root;
- updatePianoKeys(document.getElementById('scale').textContent, data.root);
- break;
- case 'cpu':
- updateCpu(data.cpu);
- break;
- case 'bpm':
- document.getElementById('bpm').textContent = data.bpm;
- break;
- case 'beat':
- updateBeat(data.beat);
- break;
- case 'chrono':
- document.getElementById('timer').textContent = formatTime(data.chrono);
- break;
- case 'players':
- formatPlayers(data.players)
- break;
- case 'help':
- const helpContainer = document.getElementById('help')
- helpContainer.textContent = data.help;
- helpContainer.style.height = helpContainer.scrollHeight + 'px';
- break;
- default:
- break;
- }
-};
+ ws.onopen = () => console.log('Panel WebSocket connected');
-ws.onclose = function() {
- console.log('WebSocket FoxDot connection closed');
-};
+ ws.onmessage = (event) => {
+ let data;
+ try {
+ data = JSON.parse(event.data);
+ } catch {
+ return;
+ }
+ switch(data.type) {
+ case 'scale':
+ document.getElementById('scale').textContent = data.scale;
+ updatePianoKeys(data.scale, document.getElementById('root').textContent);
+ break;
+ case 'root':
+ document.getElementById('root').textContent = data.root;
+ updatePianoKeys(document.getElementById('scale').textContent, data.root);
+ break;
+ case 'cpu':
+ updateCpu(data.cpu);
+ break;
+ case 'bpm':
+ document.getElementById('bpm').textContent = data.bpm;
+ break;
+ case 'beat':
+ updateBeat(data.beat);
+ break;
+ case 'chrono':
+ document.getElementById('timer').textContent = formatTime(data.chrono);
+ break;
+ case 'players':
+ formatPlayers(data.players);
+ break;
+ case 'help': {
+ const helpContainer = document.getElementById('help');
+ helpContainer.textContent = data.help;
+ helpContainer.style.height = helpContainer.scrollHeight + 'px';
+ break;
+ }
+ default:
+ break;
+ }
+ };
-ws.onerror = function(error) {
- console.error('WebSocket FoxDot error:', error);
-};
+ ws.onclose = () => {
+ console.log('Panel WebSocket closed, reconnecting in 3s...');
+ setTimeout(connectPanelWs, 3000);
+ };
+
+ ws.onerror = () => ws.close();
+}
+
+connectPanelWs();
document.getElementById('root').addEventListener('click', () => {
const pianoRoll = document.getElementById('piano-roll');
@@ -170,13 +178,28 @@ function formatPlayers(message) {
const hasSolo = players.some(p => p.solo);
const playersDiv = document.getElementById('players');
- playersDiv.innerHTML = players.map(p => `
-
- ${p.id}
- ${p.synth}
- ${p.duration}
-
- `).join('');
+ playersDiv.innerHTML = '';
+ players.forEach(p => {
+ const line = document.createElement('div');
+ line.className = `player-line${hasSolo && !p.solo ? ' player-solo' : ''}`;
+ line.dataset.playerId = p.id;
+
+ const idSpan = document.createElement('span');
+ idSpan.className = 'player-id';
+ idSpan.textContent = p.id;
+
+ const synthSpan = document.createElement('span');
+ synthSpan.className = 'player-synth';
+ synthSpan.textContent = p.synth;
+
+ const durSpan = document.createElement('span');
+ durSpan.className = 'player-duration';
+ durSpan.style.color = p.durationColor;
+ durSpan.textContent = p.duration;
+
+ line.append(idSpan, synthSpan, durSpan);
+ playersDiv.appendChild(line);
+ });
document.querySelectorAll('.player-line').forEach(line => {
line.addEventListener('click', (e) => {
@@ -190,12 +213,13 @@ function getDurationColor(totalMinutes) {
const green = '#4caf50';
const orange = '#ff9800';
const red = '#f44336';
-
+
if (totalMinutes <= 1) {
return green;
+ } else if (totalMinutes <= 3) {
+ return interpolateColor(green, orange, (totalMinutes - 1) / 2);
} else if (totalMinutes <= 5) {
- const factor = (totalMinutes - 1) / 4; // 4 = (5-1)
- return interpolateColor(green, red, factor);
+ return interpolateColor(orange, red, (totalMinutes - 3) / 2);
}
return red;
}
diff --git a/startup-renardo.py b/startup-renardo.py
deleted file mode 100644
index 1007970..0000000
--- a/startup-renardo.py
+++ /dev/null
@@ -1,246 +0,0 @@
-import asyncio
-import json
-import threading
-import websockets
-from time import *
-import os
-import re
-
-# Alias for loops
-loops = Samples.loops
-
-# Fix the missing FxList
-try:
- FxList = effect_manager
-except:
- pass
-
-
-class WebFoxDotPanelWs():
- def __init__(self, ip="localhost", port=20000):
- self.isRunning = False
- self.ip = ip
- self.port = port
-
- # OSC Server
- self.oscServer = ThreadingOSCServer((self.ip, 2887))
- self.oscServer.addDefaultHandlers()
- self.oscServer.addMsgHandler(
- "/CPU", self.receiveCpu)
- self.oscThread = threading.Thread(target=self.oscServer.serve_forever, daemon=True)
- self.oscThread.start()
- # websocket server
- self.wsClients = set()
- self.websocket_started_event = threading.Event()
- self.websocket_thread = threading.Thread(target=self.start_websocket_server, daemon=True)
- self.websocket_thread.start()
- self.websocket_started_event.wait()
- # bpm send
- self.sendBpm_thread = threading.Thread(target=self.send_bpm_periodically, daemon=True)
- self.sendBpm_thread.start()
-
- self.bpmTime = 0.2 # time cycle send Scale,Root
- self.beatTime = 0.1 # time cycle send beat
- self.plyTime = 1.0 # time cycle send player
- self.chronoTime = 1.0 # time cycle send chrono
-
- self.playerCounter = {}
- self.timeInit = time()
-
- self.threadScale = threading.Thread(target=self.sendScale, daemon=True)
- self.threadRoot = threading.Thread(target=self.sendRoot, daemon=True)
- self.threadBeat = threading.Thread(target=self.sendBeat, daemon=True)
- self.threadPlayer = threading.Thread(target=self.sendPlayer, daemon=True)
- self.threadChrono = threading.Thread(target=self.sendChrono, daemon=True)
-
- self.start()
-
- def receiveCpu(self, address, tags, contents, source):
- ''' receive CPU usage from SC by OSC and send it to websocket '''
- cpu = round(float(contents[0]), 2)
- if cpu:
- asyncio.run(self.sendWebsocket(
- json.dumps({"type": "cpu", "cpu": cpu})))
-
- async def sendWsServer(self, websocket):
- self.wsClients.add(websocket)
- try:
- async for message in websocket:
- await asyncio.gather(*[client.send(message) for client in self.wsClients])
- data = json.loads(message)
- if data["type"] == "get_autocomplete":
- await self.sendFoxdotAutocomplete()
- except websockets.ConnectionClosed:
- pass
- finally:
- self.wsClients.remove(websocket)
-
- async def mainWebsocket(self):
- ''' The websocket server '''
- async with websockets.serve(self.sendWsServer, self.ip, self.port):
- self.websocket_started_event.set()
- await asyncio.get_running_loop().create_future() # run forever
-
- def start_websocket_server(self):
- ''' For using threading '''
- print(f"Start FoxDot WebSocket server at ws://{self.ip}:{self.port}")
- asyncio.run(self.mainWebsocket())
-
- async def sendWebsocket(self, msg=""):
- ''' Send websocket msg to websocket server '''
- try:
- # send message as json format
- uri = f"ws://{self.ip}:{self.port}"
- async with websockets.connect(uri) as websocket:
- await websocket.send(msg)
- except Exception as e:
- print(f"Error sending websocket message: {e}")
-
- def send_bpm_periodically(self):
- ''' Send bpm to websocket server every second '''
- while True:
- bpm = int(Clock.get_bpm())
- asyncio.run(self.sendWebsocket(json.dumps({"type": "bpm", "bpm": bpm})))
- sleep(60/bpm)
-
- async def sendFoxdotAutocomplete(self):
- ''' Send FoxDot autocomplete data to websocket server '''
- fxList = await self.sendFxDict()
- synthList = await self.sendSynthList()
- combined_message = json.dumps({"type": "autocomplete", "autocomplete": {"loopList": loops, "fxList": fxList, "synthList": synthList}})
- await self.sendWebsocket(combined_message)
-
- async def sendFxDict(self):
- ''' Send fx list to websocket server '''
- fx_json_list = []
- for fx_name in FxList.keys():
- fxDefault = FxList[fx_name].defaults
- filtered_fx = {k: v for k,v in fxDefault.items() if not (k.endswith('_') or k.endswith('_d') or k == 'sus')}
- fx_text = ', '.join([f"{k}={v}" for k, v in filtered_fx.items()])
- fx_json_list.append({'text': fx_text, 'displayText': fx_name + '_'})
- fxDict = json.dumps({"type": "fxList", "fx": fx_json_list})
- return fx_json_list
-
- async def sendSynthList(self):
- args_to_remove = ['amp', 'sus', 'gate', 'pan', 'freq', 'mul', 'bus', 'atk', 'decay', 'rel', 'level', 'peak', 'blur', 'beat_dur', 'buf', "vib", "fmod"]
- synthList = []
- pathSynth = SYNTHDEF_DIR
- synth_list = sorted([f for f in SynthDefs])
- for syn in synth_list:
- if syn != "":
- path = os.path.join(pathSynth, syn + ".scd")
- with open(str(path), "r") as synth:
- synth = synth.readlines()
- synth_txt = [line.strip() for line in synth if line != "\n"]
- txt = str(''.join(synth_txt))
- synthname = re.findall(r"SynthDef(?:\.new)?\(\\(\w+)", txt)
- synthargs = re.findall(r"{\|(.{3,})\|(?:var)", txt)
- if (len(synthname) != 0 and len(synthargs) != 0):
- filtered_args = ', '.join([arg.strip() for arg in synthargs[0].split(', ') if arg.split('=')[0].strip() not in args_to_remove])
- synthList.append({'text': filtered_args, 'displayText': synthname[0]})
- return synthList
-
- def sendScale(self):
- ''' send Scale to OSC server '''
- try:
- while self.isRunning:
- msg = json.dumps({"type": "scale", "scale": str(Scale.default.name)})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.bpmTime*10)
- except:
- pass
-
- def sendRoot(self):
- ''' send Root to OSC server '''
- try:
- while self.isRunning:
- msg = json.dumps({"type": "root", "root": str(Root.default)})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.bpmTime*10)
- except:
- pass
-
- def sendBeat(self):
- ''' send Clock.beat to OSC server '''
- try:
- while self.isRunning:
- msg = json.dumps({"type": "beat", "beat": Clock.beat})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.beatTime)
- except:
- pass
-
- def sendPlayer(self):
- ''' send active player to OSC server '''
- try:
- while self.isRunning:
- self.addPlayerTurn()
- playerListCount = []
- soloPlayers = [p.name for p in Clock.solo.data]
- for k,v in self.playerCounter.items():
- duration = f'{divmod(v, 60)[0]:02d}:{divmod(v, 60)[1]:02d}'
- player = k.name
- if (k.synthdef in ["loop", "stretch"]):
- name = k.filename
- else:
- name = k.synthdef
- playerListCount.append(json.dumps({"player": player, "name": name, "duration": duration, "solo": player in soloPlayers}))
- msg = json.dumps({"type": "players", "players": playerListCount})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.plyTime)
- except:
- pass
-
- def sendChrono(self):
- ''' send ChronoTime to OSC server '''
- try:
- while self.isRunning:
- elapsedTime = time() - self.timeInit
- msg = json.dumps({"type": "chrono", "chrono": elapsedTime})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.chronoTime)
- except:
- pass
-
- def addPlayerTurn(self):
- ''' add one to player dictionary turn '''
- try:
- playerList = Clock.playing
- for p in playerList:
- if p in self.playerCounter.keys():
- self.playerCounter[p] += 1
- else:
- self.playerCounter[p] = 1
- # Clean non playing player
- delplayer = [
- k for k in self.playerCounter.keys() if k not in playerList]
- for d in delplayer:
- self.playerCounter.pop(d, None)
- except Exception as err:
- print("addPlayerTurn problem : ", err)
-
- def sendOnce(self, txt, helpType=""):
- ''' send on txt msg to OSC '''
- msg = json.dumps({"type": "help", "helpType": helpType, "help": txt})
- asyncio.run(self.sendWebsocket(msg))
-
- def stop(self):
- self.isRunning = False
-
- def start(self):
- self.isRunning = True
- self.threadScale.start()
- self.threadRoot.start()
- self.threadBeat.start()
- self.threadPlayer.start()
- self.threadChrono.start()
-
-try:
- wsPanel = WebFoxDotPanelWs()
-except Exception as e:
- print(f"Error starting FoxDot WebSocket server: {e}")
-
-def unsolo():
- ''' Unsolo all soloed players'''
- for p in Clock.playing:
- p.solo(0)
diff --git a/startup.py b/startup.py
index 34b7dd3..8e60c5d 100644
--- a/startup.py
+++ b/startup.py
@@ -1,237 +1,291 @@
import asyncio
import json
-import threading
-import websockets
-from time import *
import os
import re
+import threading
+from time import sleep, time
+
+import websockets
+
+from synth_definitions import FX_DESCRIPTIONS, SYNTH_DESCRIPTIONS
+
loops = Samples.loops
-class WebFoxDotPanelWs():
- def __init__(self, ip="localhost", port=20000):
- self.isRunning = False
- self.ip = ip
- self.port = port
-
- # OSC Server
- self.oscServer = ThreadingOSCServer((self.ip, 2887))
- self.oscServer.addDefaultHandlers()
- self.oscServer.addMsgHandler(
- "/CPU", self.receiveCpu)
- self.oscThread = threading.Thread(target=self.oscServer.serve_forever, daemon=True)
- self.oscThread.start()
- # websocket server
- self.wsClients = set()
- self.websocket_started_event = threading.Event()
- self.websocket_thread = threading.Thread(target=self.start_websocket_server, daemon=True)
- self.websocket_thread.start()
- self.websocket_started_event.wait()
- # bpm send
- self.sendBpm_thread = threading.Thread(target=self.send_bpm_periodically, daemon=True)
- self.sendBpm_thread.start()
-
- self.bpmTime = 0.2 # time cycle send Scale,Root
- self.beatTime = 0.1 # time cycle send beat
- self.plyTime = 1.0 # time cycle send player
- self.chronoTime = 1.0 # time cycle send chrono
-
- self.playerCounter = {}
- self.timeInit = time()
-
- self.threadScale = threading.Thread(target=self.sendScale, daemon=True)
- self.threadRoot = threading.Thread(target=self.sendRoot, daemon=True)
- self.threadBeat = threading.Thread(target=self.sendBeat, daemon=True)
- self.threadPlayer = threading.Thread(target=self.sendPlayer, daemon=True)
- self.threadChrono = threading.Thread(target=self.sendChrono, daemon=True)
-
- self.start()
-
- def receiveCpu(self, address, tags, contents, source):
- ''' receive CPU usage from SC by OSC and send it to websocket '''
- cpu = round(float(contents[0]), 2)
- if cpu:
- asyncio.run(self.sendWebsocket(
- json.dumps({"type": "cpu", "cpu": cpu})))
-
- async def sendWsServer(self, websocket):
- self.wsClients.add(websocket)
- try:
- async for message in websocket:
- await asyncio.gather(*[client.send(message) for client in self.wsClients])
- data = json.loads(message)
- if data["type"] == "get_autocomplete":
- await self.sendFoxdotAutocomplete()
- except websockets.ConnectionClosed:
- pass
- finally:
- self.wsClients.remove(websocket)
-
- async def mainWebsocket(self):
- ''' The websocket server '''
- async with websockets.serve(self.sendWsServer, self.ip, self.port):
- self.websocket_started_event.set()
- await asyncio.get_running_loop().create_future() # run forever
-
- def start_websocket_server(self):
- ''' For using threading '''
- print(f"Start FoxDot WebSocket server at ws://{self.ip}:{self.port}")
- asyncio.run(self.mainWebsocket())
-
- async def sendWebsocket(self, msg=""):
- ''' Send websocket msg to websocket server '''
- try:
- # send message as json format
- uri = f"ws://{self.ip}:{self.port}"
- async with websockets.connect(uri) as websocket:
- await websocket.send(msg)
- except Exception as e:
- print(f"Error sending websocket message: {e}")
-
- def send_bpm_periodically(self):
- ''' Send bpm to websocket server every second '''
- while True:
- bpm = int(Clock.get_bpm())
- asyncio.run(self.sendWebsocket(json.dumps({"type": "bpm", "bpm": bpm})))
- sleep(60/bpm)
-
- async def sendFoxdotAutocomplete(self):
- ''' Send FoxDot autocomplete data to websocket server '''
- fxList = await self.sendFxDict()
- synthList = await self.sendSynthList()
- combined_message = json.dumps({"type": "autocomplete", "autocomplete": {"loopList": loops, "fxList": fxList, "synthList": synthList}})
- await self.sendWebsocket(combined_message)
-
- async def sendFxDict(self):
- ''' Send fx list to websocket server '''
- fx_json_list = []
- for fx_name in FxList.keys():
- fxDefault = FxList[fx_name].defaults
- filtered_fx = {k: v for k,v in fxDefault.items() if not (k.endswith('_') or k.endswith('_d') or k == 'sus')}
- fx_text = ', '.join([f"{k}={v}" for k, v in filtered_fx.items()])
- fx_json_list.append({'text': fx_text, 'displayText': fx_name + '_'})
- fxDict = json.dumps({"type": "fxList", "fx": fx_json_list})
- return fx_json_list
-
- async def sendSynthList(self):
- args_to_remove = ['amp', 'sus', 'gate', 'pan', 'freq', 'mul', 'bus', 'atk', 'decay', 'rel', 'level', 'peak', 'blur', 'beat_dur', 'buf', "vib", "fmod"]
- synthList = []
- path = os.path.join(FOXDOT_ROOT, "osc", "scsyndef", "")
- synth_list = sorted([f for f in SynthDefs])
- for syn in synth_list:
- if syn != "":
- path = os.path.join(FOXDOT_ROOT, "osc", "scsyndef", syn + ".scd")
- with open(str(path), "r") as synth:
- synth = synth.readlines()
- synth_txt = [line.strip() for line in synth if line != "\n"]
- txt = str(''.join(synth_txt))
- synthname = re.findall(r"SynthDef(?:\.new)?\(\\(\w+)", txt)
- synthargs = re.findall(r"{\|(.{3,})\|(?:var)", txt)
- if (len(synthname) != 0 and len(synthargs) != 0):
- filtered_args = ', '.join([arg.strip() for arg in synthargs[0].split(', ') if arg.split('=')[0].strip() not in args_to_remove])
- synthList.append({'text': filtered_args, 'displayText': synthname[0]})
- return synthList
-
- def sendScale(self):
- ''' send Scale to OSC server '''
- try:
- while self.isRunning:
- msg = json.dumps({"type": "scale", "scale": str(Scale.default.name)})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.bpmTime*10)
- except:
- pass
-
- def sendRoot(self):
- ''' send Root to OSC server '''
- try:
- while self.isRunning:
- msg = json.dumps({"type": "root", "root": str(Root.default)})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.bpmTime*10)
- except:
- pass
-
- def sendBeat(self):
- ''' send Clock.beat to OSC server '''
- try:
- while self.isRunning:
- msg = json.dumps({"type": "beat", "beat": Clock.beat})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.beatTime)
- except:
- pass
-
- def sendPlayer(self):
- ''' send active player to OSC server '''
- try:
- while self.isRunning:
- self.addPlayerTurn()
- playerListCount = []
- soloPlayers = [p.name for p in Clock.solo.data]
- for k,v in self.playerCounter.items():
- duration = f'{divmod(v, 60)[0]:02d}:{divmod(v, 60)[1]:02d}'
- player = k.name
- if (k.synthdef in ["loop", "stretch"]):
- name = k.filename
- else:
- name = k.synthdef
- playerListCount.append(json.dumps({"player": player, "name": name, "duration": duration, "solo": player in soloPlayers}))
- msg = json.dumps({"type": "players", "players": playerListCount})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.plyTime)
- except:
- pass
-
- def sendChrono(self):
- ''' send ChronoTime to OSC server '''
- try:
- while self.isRunning:
- elapsedTime = time() - self.timeInit
- msg = json.dumps({"type": "chrono", "chrono": elapsedTime})
- asyncio.run(self.sendWebsocket(msg))
- sleep(self.chronoTime)
- except:
- pass
-
- def addPlayerTurn(self):
- ''' add one to player dictionary turn '''
- try:
- playerList = Clock.playing
- for p in playerList:
- if p in self.playerCounter.keys():
- self.playerCounter[p] += 1
- else:
- self.playerCounter[p] = 1
- # Clean non playing player
- delplayer = [
- k for k in self.playerCounter.keys() if k not in playerList]
- for d in delplayer:
- self.playerCounter.pop(d, None)
- except Exception as err:
- print("addPlayerTurn problem : ", err)
-
- def sendOnce(self, txt, helpType=""):
- ''' send on txt msg to OSC '''
- msg = json.dumps({"type": "help", "helpType": helpType, "help": txt})
- asyncio.run(self.sendWebsocket(msg))
-
- def stop(self):
- self.isRunning = False
-
- def start(self):
- self.isRunning = True
- self.threadScale.start()
- self.threadRoot.start()
- self.threadBeat.start()
- self.threadPlayer.start()
- self.threadChrono.start()
+# Renardo compatibility: FxList is named differently
+try:
+ FxList = effect_manager
+except Exception:
+ pass
+
+# Resolve synth definition directory for both FoxDot and Renardo
+try:
+ _SYNTH_DIR = SYNTHDEF_DIR
+except Exception:
+ _SYNTH_DIR = os.path.join(FOXDOT_ROOT, "osc", "scsyndef")
+
+_SYNTH_ARGS_IGNORE = {
+ "amp",
+ "sus",
+ "gate",
+ "pan",
+ "freq",
+ "mul",
+ "bus",
+ "atk",
+ "decay",
+ "rel",
+ "level",
+ "peak",
+ "blur",
+ "beat_dur",
+ "buf",
+ "vib",
+ "fmod",
+}
+
+
+class WebFoxDotPanelWs:
+ def __init__(self, ip="localhost", port=20000):
+ self.is_running = False
+ self.ip = ip
+ self.port = port
+ self.ws_clients = set()
+ self.player_counter = {}
+ self.time_init = time()
+
+ self.bpm_time = 0.2
+ self.beat_time = 0.1
+ self.player_time = 1.0
+ self.chrono_time = 1.0
+
+ self._start_osc_server()
+ self._start_websocket()
+ self._start_bpm_sender()
+
+ self._periodic_threads = [
+ threading.Thread(
+ target=self._send_loop,
+ args=("scale", self._get_scale, self.bpm_time * 10),
+ daemon=True,
+ ),
+ threading.Thread(
+ target=self._send_loop,
+ args=("root", self._get_root, self.bpm_time * 10),
+ daemon=True,
+ ),
+ threading.Thread(
+ target=self._send_loop,
+ args=("beat", self._get_beat, self.beat_time),
+ daemon=True,
+ ),
+ threading.Thread(target=self._send_player, daemon=True),
+ threading.Thread(
+ target=self._send_loop,
+ args=("chrono", self._get_chrono, self.chrono_time),
+ daemon=True,
+ ),
+ ]
+
+ self.start()
+
+ def _start_osc_server(self):
+ self._osc_server = ThreadingOSCServer((self.ip, 2887))
+ self._osc_server.addDefaultHandlers()
+ self._osc_server.addMsgHandler("/CPU", self._receive_cpu)
+ threading.Thread(target=self._osc_server.serve_forever, daemon=True).start()
+
+ def _start_websocket(self):
+ self._ws_event = threading.Event()
+ threading.Thread(target=self._run_ws_server, daemon=True).start()
+ self._ws_event.wait()
+
+ def _start_bpm_sender(self):
+ threading.Thread(target=self._send_bpm_periodically, daemon=True).start()
+
+ def _receive_cpu(self, _address, _tags, contents, _source):
+ cpu = round(float(contents[0]), 2)
+ if cpu:
+ asyncio.run(self._send_ws(json.dumps({"type": "cpu", "cpu": cpu})))
+
+ async def _handle_ws_client(self, websocket):
+ self.ws_clients.add(websocket)
+ try:
+ async for message in websocket:
+ await asyncio.gather(*[c.send(message) for c in self.ws_clients])
+ data = json.loads(message)
+ if data["type"] == "get_autocomplete":
+ await self._send_autocomplete()
+ except websockets.ConnectionClosed:
+ pass
+ finally:
+ self.ws_clients.remove(websocket)
+
+ async def _ws_main(self):
+ async with websockets.serve(self._handle_ws_client, self.ip, self.port):
+ self._ws_event.set()
+ await asyncio.get_running_loop().create_future()
+
+ def _run_ws_server(self):
+ print(f"Start FoxDot WebSocket server at ws://{self.ip}:{self.port}")
+ asyncio.run(self._ws_main())
+
+ async def _send_ws(self, msg=""):
+ try:
+ uri = f"ws://{self.ip}:{self.port}"
+ async with websockets.connect(uri) as ws:
+ await ws.send(msg)
+ except Exception as e:
+ print(f"Error sending websocket message: {e}")
+
+ def _send_bpm_periodically(self):
+ while True:
+ bpm = int(Clock.get_bpm())
+ asyncio.run(self._send_ws(json.dumps({"type": "bpm", "bpm": bpm})))
+ sleep(60 / bpm)
+
+ # --- Data getters for periodic sends ---
+
+ def _get_scale(self):
+ return {"type": "scale", "scale": str(Scale.default.name)}
+
+ def _get_root(self):
+ return {"type": "root", "root": str(Root.default)}
+
+ def _get_beat(self):
+ return {"type": "beat", "beat": Clock.beat}
+
+ def _get_chrono(self):
+ return {"type": "chrono", "chrono": time() - self.time_init}
+
+ def _send_loop(self, name, getter, interval):
+ try:
+ while self.is_running:
+ asyncio.run(self._send_ws(json.dumps(getter())))
+ sleep(interval)
+ except Exception:
+ pass
+
+ def _send_player(self):
+ try:
+ while self.is_running:
+ self._update_player_counter()
+ solo_players = [p.name for p in Clock.solo.data]
+ player_list = [
+ json.dumps(
+ {
+ "player": k.name,
+ "name": k.filename
+ if k.synthdef in ("loop", "stretch")
+ else k.synthdef,
+ "duration": f"{divmod(v, 60)[0]:02d}:{divmod(v, 60)[1]:02d}",
+ "solo": k.name in solo_players,
+ }
+ )
+ for k, v in self.player_counter.items()
+ ]
+ asyncio.run(
+ self._send_ws(
+ json.dumps({"type": "players", "players": player_list})
+ )
+ )
+ sleep(self.player_time)
+ except Exception:
+ pass
+
+ def _update_player_counter(self):
+ try:
+ playing = Clock.playing
+ for p in playing:
+ self.player_counter[p] = self.player_counter.get(p, 0) + 1
+ for k in [k for k in self.player_counter if k not in playing]:
+ del self.player_counter[k]
+ except Exception as err:
+ print(f"_update_player_counter error: {err}")
+
+ # --- Autocomplete ---
+
+ async def _send_autocomplete(self):
+ fx_list = await self._get_fx_list()
+ synth_list = await self._get_synth_list()
+ msg = json.dumps(
+ {
+ "type": "autocomplete",
+ "autocomplete": {
+ "loopList": loops,
+ "fxList": fx_list,
+ "synthList": synth_list,
+ },
+ }
+ )
+ await self._send_ws(msg)
+
+ async def _get_fx_list(self):
+ result = []
+ for fx_name in FxList.keys():
+ defaults = FxList[fx_name].defaults
+ filtered = {
+ k: v
+ for k, v in defaults.items()
+ if not (k.endswith("_") or k.endswith("_d") or k == "sus")
+ }
+ result.append(
+ {
+ "text": ", ".join(f"{k}={v}" for k, v in filtered.items()),
+ "displayText": fx_name + "_",
+ "description": FX_DESCRIPTIONS.get(fx_name, ""),
+ }
+ )
+ return result
+
+ async def _get_synth_list(self):
+ result = []
+ for syn in sorted(SynthDefs):
+ if not syn:
+ continue
+ path = os.path.join(_SYNTH_DIR, syn + ".scd")
+ with open(path) as f:
+ content = "".join(line.strip() for line in f if line.strip())
+ names = re.findall(r"SynthDef(?:\.new)?\(\\(\w+)", content)
+ args = re.findall(r"{\|(.{3,})\|(?:var)", content)
+ if names and args:
+ filtered = ", ".join(
+ a.strip()
+ for a in args[0].split(", ")
+ if a.split("=")[0].strip() not in _SYNTH_ARGS_IGNORE
+ )
+ result.append(
+ {
+ "text": filtered,
+ "displayText": names[0],
+ "description": SYNTH_DESCRIPTIONS.get(names[0], ""),
+ }
+ )
+ return result
+
+ # --- Control ---
+
+ def send_once(self, txt, help_type=""):
+ asyncio.run(
+ self._send_ws(
+ json.dumps({"type": "help", "helpType": help_type, "help": txt})
+ )
+ )
+
+ def stop(self):
+ self.is_running = False
+
+ def start(self):
+ self.is_running = True
+ for t in self._periodic_threads:
+ t.start()
+
try:
- wsPanel = WebFoxDotPanelWs()
+ ws_panel = WebFoxDotPanelWs()
except Exception as e:
- print(f"Error starting FoxDot WebSocket server: {e}")
+ print(f"Error starting FoxDot WebSocket server: {e}")
+
def unsolo():
- ''' Unsolo all soloed players'''
for p in Clock.playing:
- p.solo(0)
\ No newline at end of file
+ p.solo(0)
diff --git a/synth_definitions.py b/synth_definitions.py
new file mode 100644
index 0000000..e2fa208
--- /dev/null
+++ b/synth_definitions.py
@@ -0,0 +1,90 @@
+"""Static descriptions for FoxDot/Renardo SynthDefs and effects."""
+
+SYNTH_DESCRIPTIONS = {
+ "play1": "Mono sample playback",
+ "play2": "Stereo sample playback",
+ "audioin": "Audio input from sound card channels",
+ "noise": "LF noise-based sound",
+ "dab": "High-pass filtered sawtooth blend",
+ "varsaw": "Variable sawtooth wave",
+ "lazer": "Complex modulated sawtooth",
+ "growl": "Sine oscillator with saw modulation",
+ "bass": "Tri/sawtooth/feedback sine blend",
+ "dirt": "Sawtooth-based gritty sound",
+ "crunch": "Noise-based percussive sound",
+ "rave": "Dynamic stochastic synthesis",
+ "scatter": "Modulated sawtooth pair",
+ "charm": "Filtered sine/sawtooth combination",
+ "bell": "Klank resonator instrument",
+ "gong": "Klank with feedback modulation",
+ "soprano": "Vibrato-modulated high sine",
+ "dub": "Triangle/feedback sine blend",
+ "viola": "Phase-modulated oscillator",
+ "scratch": "Vibrato with frequency modulation",
+ "klank": "Resonant filtered noise",
+ "feel": "Klank-based warm resonance",
+ "glass": "Klank-based bright resonance",
+ "soft": "Klank-based soft resonance",
+ "quin": "Klank-based quintet resonance",
+ "pluck": "Plucked string sound",
+ "spark": "Bright transient sound",
+ "blip": "Short bright blip",
+ "ripple": "Pulse-based ripple pattern",
+ "creep": "Modulated creeping pattern",
+ "star": "Sawtooth with complex phase modulation",
+ "jbass": "ADSR-controlled bass",
+ "orient": "Oriental-flavored synth",
+ "zap": "Quick percussive zap",
+ "marimba": "Marimba-like percussive tone",
+ "fuzz": "Fuzzy distorted sound",
+ "bug": "Glitchy bug-like sound",
+ "pulse": "Pulse wave synth",
+ "saw": "Sawtooth wave synth",
+ "snick": "Short snick percussive",
+ "twang": "Twangy plucked string",
+ "karp": "Karplus-Strong plucked string",
+ "arpy": "Arpeggiated synth tone",
+ "nylon": "Nylon string guitar",
+ "donk": "Percussive donk hit",
+ "squish": "Squished modulated sound",
+ "swell": "Swelling pad sound",
+ "razz": "Razzy buzzing synth",
+ "sitar": "Sitar-like resonant string",
+ "piano": "MDA Piano emulation (requires SC3 plugins)",
+ "sawbass": "Sawtooth bass",
+ "prophet": "Prophet-style polysynth",
+ "pads": "Sustained pad sound",
+ "pasha": "Pasha atmospheric synth",
+ "ambi": "Ambient atmospheric synth",
+ "space": "Spacey reverberant synth",
+ "keys": "Electric keys",
+ "dbass": "Deep bass",
+ "sinepad": "Sine wave pad",
+ "loop": "Loop sample playback",
+ "stretch": "Time-stretched sample playback",
+}
+
+FX_DESCRIPTIONS = {
+ "vib": "Vibrato - pitch oscillation",
+ "slide": "Slide to next note pitch",
+ "slidefrom": "Slide from a pitch to current",
+ "bend": "Pitch bend",
+ "coarse": "Coarse granular effect",
+ "striate": "Striated granular effect",
+ "pshift": "Pitch shift",
+ "hpf": "High-pass filter",
+ "lpf": "Low-pass filter",
+ "swell": "Volume swell envelope",
+ "bpf": "Band-pass filter",
+ "crush": "Bitcrusher distortion",
+ "dist": "Distortion",
+ "spin": "Stereo spinning panner",
+ "cut": "Cut/gate effect",
+ "room": "Reverb room size",
+ "chop": "Amplitude chopping",
+ "tremolo": "Tremolo amplitude modulation",
+ "echo": "Echo/delay effect",
+ "formant": "Formant vowel filter",
+ "shape": "Waveshaping distortion",
+ "drive": "Overdrive distortion",
+}