├── .config ├── eslint.mjs ├── tsconfig.json └── tsconfig.tests.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── changed_files │ │ └── action.yml └── workflows │ ├── linter.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .markdownlint.jsonc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── assets ├── manim-notebook-logo.png └── walkthrough │ ├── commands.svg │ ├── manim-installation.md │ ├── preview-cell.svg │ ├── sample_scene.py │ ├── settings.svg │ ├── shortcuts.svg │ └── wiki.svg ├── esbuild.js ├── package-lock.json ├── package.json ├── src ├── export.ts ├── extension.ts ├── logger.ts ├── manimCell.ts ├── manimShell.ts ├── manimVersion.ts ├── patches │ ├── applyPatches.ts │ └── install_windows_paste_patch.py ├── previewCode.ts ├── pythonParsing.ts ├── startStopScene.ts ├── utils │ ├── fileUtil.ts │ ├── multiStepQuickPickUtil.ts │ ├── terminal.ts │ ├── testing.ts │ └── venv.ts └── walkthrough.ts └── tests ├── activation.test.ts ├── cellRanges.test.ts ├── fixtures ├── basic.py ├── crazy.py ├── detection_basic.py ├── detection_class_definition.py ├── detection_inside_construct.py ├── detection_multiple_inheritance.py ├── detection_multiple_methods.py ├── detection_syntax.py └── laggy.py ├── main.ts ├── preview.test.ts ├── scripts └── setTestEntryPoint.js └── utils ├── editor.ts ├── installManim.ts ├── manimCaller.ts ├── manimInstaller.ts ├── prototype.ts ├── terminal.ts └── testRunner.ts /.config/eslint.mjs: -------------------------------------------------------------------------------- 1 | import parserTs from "@typescript-eslint/parser"; 2 | import stylistic from "@stylistic/eslint-plugin"; 3 | import globals from "globals"; 4 | 5 | export default [ 6 | { 7 | // Globally ignore the following paths 8 | ignores: [ 9 | "node_modules/", 10 | ], 11 | }, 12 | { 13 | files: ["**/*.ts", "**/*.js", "**/*.mjs"], 14 | plugins: { 15 | "@stylistic": stylistic, 16 | }, 17 | rules: { 18 | ...stylistic.configs.customize({ 19 | "indent": 2, 20 | "jsx": false, 21 | "quote-props": "always", 22 | "semi": true, 23 | }).rules, 24 | "@stylistic/brace-style": ["error", "1tbs"], 25 | "@stylistic/max-len": ["error", { 26 | code: 100, comments: 80, ignoreUrls: true, ignoreRegExpLiterals: true }], 27 | "@stylistic/quotes": ["error", "double", { avoidEscape: true }], 28 | "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 29 | }, 30 | languageOptions: { 31 | ecmaVersion: "latest", 32 | parser: parserTs, 33 | sourceType: "module", 34 | globals: { 35 | ...globals.node, 36 | }, 37 | }, 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "target": "ES2024", 5 | "lib": [ 6 | "ES2024" 7 | ], 8 | "sourceMap": true, 9 | "strict": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "removeComments": true, 12 | "outDir": "../out/", 13 | "rootDir": "../" 14 | }, 15 | "include": [ 16 | "../src/**/*.ts", 17 | ], 18 | } -------------------------------------------------------------------------------- /.config/tsconfig.tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "../tests/**/*.ts", 5 | ], 6 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: When you encounter bugs related to the extension 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | Make sure you have the latest [Manim release](https://github.com/3b1b/manim/releases) installed or a commit from the [Manim `master` branch](https://github.com/3b1b/manim) that is even newer. Some system information is recorded in the log file, see down below. 15 | 16 | ## ⭕ Description 17 | 18 | - Be clear and concise and describe what is not working for you in proper English sentences. 19 | - Hopefully, you can reproduce your issue in a reliable way. If this is the case, note down the steps here. 20 | - What is the expected behavior? 21 | - If you want to cut turnaround times, please provide a screen recording for us to better understand what you mean. 22 | 23 | ## 🧾 Logs 24 | 25 | Please always attach a log file even if you cannot reproduce an error since the log will include some information about your system like the Manim Notebook version you're using. It takes just a few clicks to create a log file, see the [Wiki here](https://github.com/Manim-Notebook/manim-notebook/wiki/%F0%9F%A4%A2-Troubleshooting#-record-log-file). Then just drag and drop the file here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for a new Manim Notebook feature 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | ## 🎈 Motivation 15 | 16 | Thanks for proposing a new feature to Manim Notebook. Before describing the feature, we need to know *what problem* it solves in the first place. Don't shy away from giving context and background information. What is the current state that you don't like and why? Maybe even include some code snippets or a screen recording. 17 | 18 | ## 💠 Feature 19 | 20 | Now, how could a new feature solve this? How would it look like? Be as precise as possible. Maybe even include some UI mockup? 21 | 22 | ## ♻ Workaround 23 | 24 | Maybe you found an alternative way to solve your problem in the meantime. Describe it here such that other users can profit from it until we might implement your desired feature. 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/actions/changed_files/action.yml: -------------------------------------------------------------------------------- 1 | # Why is this file in a subdirectory? Because GitHub Actions requires it to be :( 2 | # see: https://github.com/orgs/community/discussions/26245#discussioncomment-5962450 3 | name: "Get changed files" 4 | description: "Checks out the code and returns the filenames of files that have changed in the pull request" 5 | 6 | inputs: 7 | file-extensions: 8 | # for example: "\.rb$" or something like "\.js$|\.js.erb$" 9 | description: "Regex expressions for grep to filter for specific files" 10 | required: true 11 | 12 | outputs: 13 | changed-files: 14 | description: "A space-separated list of the files that have changed in the pull request" 15 | value: ${{ steps.get-changed-files.outputs.files }} 16 | 17 | runs: 18 | using: "composite" 19 | steps: 20 | # This has to be done in the main workflow, not in the action, because 21 | # otherwise this reusable action is not available in the workflow. 22 | # - name: "Checkout code (on a PR branch)" 23 | # uses: actions/checkout@v4 24 | # with: 25 | # fetch-depth: 2 # to also fetch parent of PR 26 | 27 | # Adapted from this great comment [1]. Git diff adapted from [2]. 28 | # "|| test $? = 1;" is used to ignore the exit code of grep when no files 29 | # are found matching the pattern. For the "three dots" ... syntax, see [3]. 30 | # 31 | # Resources: 32 | # number [1] being most important 33 | # [1] https://github.com/actions/checkout/issues/520#issuecomment-1167205721 34 | # [2] https://robertfaldo.medium.com/commands-to-run-rubocop-and-specs-you-changed-in-your-branch-e6d2f2e4110b 35 | # [3] https://community.atlassian.com/t5/Bitbucket-questions/Git-diff-show-different-files-than-PR-Pull-Request/qaq-p/2331786 36 | - name: Get changed files 37 | shell: bash 38 | id: get-changed-files 39 | run: | 40 | files_pretty=$(git diff --name-only --diff-filter=ACMR -r HEAD^1...HEAD | egrep '${{inputs.file-extensions}}' || test $? = 1;) 41 | printf "🎴 Changed files: \n$files_pretty" 42 | echo "files=$(echo ${files_pretty} | xargs)" >> $GITHUB_OUTPUT 43 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | # Trigger each time HEAD branch is updated in a pull request 4 | # see https://github.com/orgs/community/discussions/26366 5 | on: 6 | pull_request: 7 | types: [opened, reopened, synchronize, ready_for_review] 8 | 9 | jobs: 10 | 11 | eslint: 12 | name: ESLint (JS) 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 2 # to also fetch parent of PR (used to get changed files) 19 | 20 | - name: Get changed files 21 | id: ts-changed 22 | uses: ./.github/actions/changed_files/ 23 | with: 24 | file-extensions: \.js$|\.mjs$|\.ts$ 25 | 26 | - name: Setup Node.js 27 | if: ${{ steps.ts-changed.outputs.changed-files != ''}} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: '20' # End of Life (EOL): April 2026 31 | cache: 'npm' 32 | 33 | - name: Install dependencies 34 | if: ${{ steps.ts-changed.outputs.changed-files != ''}} 35 | run: npm install 36 | 37 | # with ESLint v9 --ignore-path does not exist anymore 38 | # see [1] for the PR. However, my feeling for this is totally reflected 39 | # by [2]. Hopefully, it will come back in future versions. 40 | # [1] https://github.com/eslint/eslint/pull/16355 41 | # [2] https://github.com/eslint/eslint/issues/16264#issuecomment-1292858747 42 | - name: Run ESLint 43 | if: ${{ steps.ts-changed.outputs.changed-files != ''}} 44 | run: | 45 | echo "🚨 Running ESLint version: $(npx --quiet eslint --version)" 46 | npx eslint --config ./.config/eslint.mjs --max-warnings 0 --no-warn-ignored ${{ steps.ts-changed.outputs.changed-files }} 47 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: 4 | - created 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | 13 | - name: Install Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | 18 | - name: Install dependencies 19 | run: npm install 20 | 21 | - name: Package extension 22 | run: npm run package-vsce 23 | 24 | - name: Upload VSIX artifact to GitHub Release notes 25 | if: startsWith(github.ref, 'refs/tags/') 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: manim-notebook.vsix 29 | path: manim-notebook-*.vsix 30 | if-no-files-found: error 31 | 32 | - name: Publish 33 | if: startsWith(github.ref, 'refs/tags/') 34 | run: npm run deploy 35 | env: 36 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | # Trigger each time HEAD branch is updated in a pull request 8 | # see https://github.com/orgs/community/discussions/26366 9 | pull_request: 10 | types: [opened, reopened, synchronize, ready_for_review] 11 | 12 | jobs: 13 | 14 | test: 15 | # Adapted from: 16 | # https://code.visualstudio.com/api/working-with-extensions/continuous-integration#github-actions 17 | name: Integration tests 18 | environment: testing 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: [macos-latest, ubuntu-latest, windows-latest] 24 | runs-on: ${{ matrix.os }} 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - name: Install Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 22.x # LTS until Oct 2025 33 | - run: npm install 34 | - name: Setup Python 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: '3.13' # end of support: 2029-10 38 | # hopefully, this can be cached in the future 39 | # see: https://github.com/actions/setup-python/issues/991 40 | 41 | - name: Install Mesa (Linux) 42 | if: runner.os == 'Linux' 43 | run: | 44 | # Install OpenGL Mesa Utils 45 | # https://idroot.us/install-mesa-drivers-ubuntu-24-04/ 46 | sudo add-apt-repository ppa:kisak/kisak-mesa 47 | sudo apt-get update 48 | sudo apt-get install mesa-utils -y 49 | 50 | # Install Pango 51 | sudo apt-get install libpango1.0-dev -y 52 | 53 | # Work around 'NoneType' object has no attribute 'glGetError' 54 | # https://github.com/MPI-IS/mesh/issues/23#issuecomment-607784234 55 | sudo apt-get install python3-opengl 56 | 57 | # Install copy-paste mechanism to avoid ClipboardUnavailable errors 58 | # (python pyperclip makes use of xclip on Linux) 59 | sudo apt-get install xclip -y 60 | 61 | - name: Install Mesa (MinGW, Windows) 62 | if: runner.os == 'Windows' 63 | run: | 64 | # Install OpenGL pre-built Mesa binaries from mesa-dist-win 65 | Invoke-WebRequest -Uri https://github.com/pal1000/mesa-dist-win/releases/download/24.3.2/mesa3d-24.3.2-release-mingw.7z -OutFile mesa3d.7z 66 | 67 | # Extract (on purpose no space between -o and mesa3d) 68 | 7z x mesa3d.7z -omesa3d 69 | 70 | # Install system-wide (option 1: core desktop OpenGL drivers) 71 | .\mesa3d\systemwidedeploy.cmd 1 72 | 73 | - name: Test OpenGL 74 | run: | 75 | if [ "$RUNNER_OS" == "Linux" ]; then 76 | xvfb-run -a glxinfo | grep "OpenGL version" 77 | elif [ "$RUNNER_OS" == "macOS" ]; then 78 | echo "We don't know a way to install the glxinfo command on macOS," 79 | echo "so we can't check the OpenGL version :(" 80 | elif [ "$RUNNER_OS" == "Windows" ]; then 81 | # Download wglinfo (analogous to glxinfo) 82 | curl -L -O https://github.com/gkv311/wglinfo/releases/latest/download/wglinfo64.exe 83 | chmod +x wglinfo64.exe 84 | ./wglinfo64.exe | grep "OpenGL version" 85 | else 86 | echo "Unknown OS" 87 | exit 1 88 | fi 89 | shell: bash 90 | 91 | - name: Install Manim (pretest) 92 | run: | 93 | if [ "$RUNNER_OS" == "Linux" ]; then 94 | xvfb-run -a npm run pretest 95 | else 96 | npm run pretest 97 | fi 98 | shell: bash 99 | 100 | - name: Run tests 101 | run: | 102 | if [ "$RUNNER_OS" == "Linux" ]; then 103 | # Start an X virtual framebuffer (Xvfb) server, which emulates a 104 | # display server without requiring a physical display. 105 | xvfb-run -a npm run test-without-pre-or-posttest 106 | else 107 | npm run test-without-pre-or-posttest 108 | fi 109 | shell: bash 110 | 111 | - name: Run posttest 112 | run: npm run posttest 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | *.log 3 | *.mp4 4 | .DS_Store 5 | out 6 | out-test 7 | dist 8 | node_modules 9 | _debug_scenes/ 10 | .vscode-test/ 11 | *.vsix 12 | *.code-workspace 13 | *.pyc 14 | **/__pycache__/**/* 15 | -------------------------------------------------------------------------------- /.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD033": false, // Inline HTML 4 | "MD041": false, // first line header 5 | "MD013": false // line length 6 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "streetsidesoftware.code-spell-checker", 7 | "connor4312.esbuild-problem-matchers" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/dist/**/*.js" 17 | ], 18 | "preLaunchTask": "Watch (Default)", 19 | "sourceMaps": true 20 | }, 21 | { 22 | "name": "Test Extension", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "${workspaceFolder}/tests/fixtures", 28 | "--disable-extensions", 29 | "--extensionDevelopmentPath=${workspaceFolder}", 30 | "--extensionTestsPath=${workspaceFolder}/out-test/tests/utils/testRunner.js" 31 | ], 32 | "outFiles": [ 33 | "${workspaceFolder}/out-test/tests/**/*.js" 34 | ], 35 | "preLaunchTask": "Prepare For Tests", 36 | "sourceMaps": true, 37 | "env": { 38 | // also see tests/main.ts 39 | "IS_TESTING": "true", 40 | "TEST_BASE_PATH": "${workspaceFolder}" 41 | } 42 | } 43 | ] 44 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | ////////////////////////////////////// 3 | // Linting (TS with ESLint) 4 | ////////////////////////////////////// 5 | "editor.formatOnSave": false, // it still autosaves with the options below 6 | // https://eslint.style/guide/faq#how-to-auto-format-on-save 7 | // https://github.com/microsoft/vscode-eslint#settings-options 8 | "[typescript]": { 9 | "editor.formatOnSave": false, // to avoid formatting twice (ESLint + VSCode) 10 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 11 | }, 12 | "[javascript]": { 13 | "editor.formatOnSave": false, // to avoid formatting twice (ESLint + VSCode) 14 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 15 | }, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": "explicit" 18 | }, 19 | // this disables VSCode built-in formatter (instead we want to use ESLint) 20 | "typescript.validate.enable": false, 21 | "javascript.validate.enable": false, 22 | "eslint.format.enable": true, // use ESLint as formatter 23 | "eslint.options": { 24 | "overrideConfigFile": ".config/eslint.mjs" 25 | }, 26 | ////////////////////////////////////// 27 | // TypeScript 28 | ////////////////////////////////////// 29 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 30 | "typescript.tsc.autoDetect": "off", 31 | ////////////////////////////////////// 32 | // Files & Folders 33 | ////////////////////////////////////// 34 | "files.exclude": { 35 | "out": false, 36 | "node_modules": true, 37 | ".vscode-test": true 38 | }, 39 | "search.exclude": { 40 | "out": true // set this to false to include "out" folder in search results 41 | }, 42 | ////////////////////////////////////// 43 | // Testing 44 | ////////////////////////////////////// 45 | // https://github.com/microsoft/vscode-extension-test-runner/issues/57 46 | // "extension-test-runner.debugOptions": { 47 | // "preLaunchTask": "Watch test files" 48 | // }, 49 | ////////////////////////////////////// 50 | // Editor 51 | ////////////////////////////////////// 52 | "editor.indentSize": 2, 53 | "editor.tabSize": 2, 54 | "[python]": { 55 | "editor.indentSize": 4, 56 | "editor.tabSize": 4 57 | }, 58 | "editor.insertSpaces": true, 59 | "editor.detectIndentation": false, 60 | "editor.renderFinalNewline": "on", 61 | "editor.wordWrap": "wordWrapColumn", 62 | "editor.wordWrapColumn": 100, // toggle via Alt + Z shortcut 63 | "editor.mouseWheelZoom": true, 64 | "editor.rulers": [ 65 | { 66 | "column": 80, // soft limit 67 | "color": "#e5e5e5" 68 | }, 69 | { 70 | "column": 100, // hard limit 71 | "color": "#c9c9c9" 72 | } 73 | ], 74 | ////////////////////////////////////// 75 | // Git 76 | ////////////////////////////////////// 77 | "git.inputValidation": true, 78 | "git.inputValidationSubjectLength": 50, 79 | "git.inputValidationLength": 72, 80 | ////////////////////////////////////// 81 | // Spell Checker 82 | ////////////////////////////////////// 83 | "cSpell.words": [ 84 | "ansi", 85 | "Ansi", 86 | "audioop", 87 | "autoplay", 88 | "autoreload", 89 | "cmds", 90 | "elif", 91 | "ensurepip", 92 | "github", 93 | "glxinfo", 94 | "importlib", 95 | "ipython", 96 | "Keybord", 97 | "kisak", 98 | "laggy", 99 | "libglut", 100 | "libpango", 101 | "logfile", 102 | "Logfile", 103 | "manim", 104 | "Manim", 105 | "MANIM", 106 | "manimgl", 107 | "manimlib", 108 | "Millis", 109 | "MSVC", 110 | "nodenext", 111 | "opengl", 112 | "Pango", 113 | "posttest", 114 | "prerun", 115 | "pycache", 116 | "Pyglet", 117 | "pyperclip", 118 | "Redetects", 119 | "Sanderson", 120 | "Sanderson's", 121 | "setuptools", 122 | "systemwidedeploy", 123 | "trippy", 124 | "venv", 125 | "virtualenvs", 126 | "wglinfo", 127 | "xclip", 128 | "youtube", 129 | "ZINK" 130 | ] 131 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Watch (Default)", 8 | "dependsOn": [ 9 | "npm: watch:tsc", 10 | "npm: watch:esbuild" 11 | ], 12 | "presentation": { 13 | "reveal": "never" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "watch:tsc", 23 | "group": "build", 24 | "problemMatcher": "$tsc-watch", 25 | "isBackground": true, 26 | "label": "npm: watch:tsc", 27 | "presentation": { 28 | "group": "watch", 29 | "reveal": "never" 30 | } 31 | }, 32 | { 33 | "type": "npm", 34 | "script": "watch:esbuild", 35 | "group": "build", 36 | "problemMatcher": "$esbuild-watch", 37 | "isBackground": true, 38 | "label": "npm: watch:esbuild", 39 | "presentation": { 40 | "group": "watch", 41 | "reveal": "never" 42 | } 43 | }, 44 | { 45 | "type": "npm", 46 | "script": "watch:tests", 47 | "group": "build", 48 | "problemMatcher": "$tsc-watch", 49 | "isBackground": true, 50 | "label": "npm: watch:tests", 51 | "presentation": { 52 | "group": "watch", 53 | "reveal": "never" 54 | } 55 | }, 56 | { 57 | "label": "Install Manim", 58 | "type": "npm", 59 | "script": "install-manim", 60 | "presentation": { 61 | "reveal": "always" 62 | }, 63 | "group": { 64 | "kind": "build" 65 | } 66 | }, 67 | { 68 | "label": "Prepare For Tests", 69 | "dependsOrder": "sequence", 70 | "dependsOn": [ 71 | "npm: watch:tsc", 72 | "npm: watch:esbuild", 73 | "npm: watch:tests", 74 | "Install Manim" 75 | ], 76 | "presentation": { 77 | "reveal": "never" 78 | }, 79 | "group": { 80 | "kind": "build", 81 | "isDefault": true 82 | } 83 | }, 84 | ] 85 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/** 2 | .config/** 3 | .vscode/** 4 | .vscode-test/** 5 | src/** 6 | tests/** 7 | tmp/** 8 | out-test/** 9 | .gitignore 10 | **/eslint.config.mjs 11 | **/*.map 12 | **/*.ts 13 | **/.vscode-test.* 14 | CHANGELOG.md 15 | CONTRIBUTING.md 16 | .markdownlint.jsonc 17 | node_modules/** 18 | esbuild.js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Our [Releases section](https://github.com/Manim-Notebook/manim-notebook/releases) documents all changes made to the Manim Notebook extension. This includes new features, bug fixes, and other improvements. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | See the [Developing Guide](https://github.com/Manim-Notebook/manim-notebook/wiki/Developing) in our Wiki. 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Manim Notebook Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Manim Notebook Logo showing a physical notebook with an 'M' letter on its title page 4 | 5 | 6 |
7 |

Manim Notebook

8 |

9 | VSCode extension to interactively preview your Manim animations 10 |

11 |
12 | 13 |
14 | VSCode Marketplace 15 | | 16 | GitHub 17 |
18 |
19 | 20 |
21 | 22 | > [!warning] 23 | > This extension is for [3b1b's Manim library](https://github.com/3b1b/manim) and *NOT* the [Manim Community Edition (Manim CE)](https://www.manim.community/). 24 | 25 | [**Manim Notebook Quick Showcase Video**](https://github.com/user-attachments/assets/24eab702-e351-4cc9-8f7b-7b94c54b4072) 26 | 27 | ## 🎈 What is this? 28 | 29 | Manim Notebook is a VSCode extension tailored to your needs when writing Python code to animate mathematical concepts with 3Blue1Brown's [Manim library](https://github.com/3b1b/manim). It's *NOT* a Jupyter Notebook; instead it enriches your existing Python files with interactive Manim cells that let you live-preview parts of the code and instantly see the animations. 30 | 31 | Originally, the motivation for this extension was Grant Sanderson's video [How I animate 3Blue1Brown](https://youtu.be/rbu7Zu5X1zI) where he shows his Manim workflow in Sublime Text. This extension brings a similar workflow to VSCode but even goes further and provides a rich VSCode integration. 32 | 33 | ## 💻 Usage 34 | 35 | Our VSCode **walkthrough** will guide you through the first steps and provide a sample file. The walkthrough should open automatically upon installation of [the Manim Notebook extension](https://marketplace.visualstudio.com/items?itemName=Manim-Notebook.manim-notebook). If not, you can invoke it manually: open the command palette (`Ctrl/Cmd + Shift + P`) and search for `Manim Notebook: Open Walkthrough`. 36 | 37 | The main concept is that of a Manim Cell, which is just a regular Python comment, but one that starts with `##` instead of `#`. 38 | 39 | ![manim-cell-preview](https://github.com/user-attachments/assets/577a93cb-0d05-4fa7-b1a9-52c1ccf2e5dc) 40 | 41 | > [!tip] 42 | > For customization options, troubleshooting tips and a lot more, check out the [Wiki](https://github.com/Manim-Notebook/manim-notebook/wiki/). 43 | 44 | --- 45 | 46 | ## 🚀 Features 47 | 48 | - **Manim Cells**. Split your code into Manim Cells that start with `##`. You will be presented with a CodeLens to preview the animation (and you can even _reload_ your changed Python files such that you don't have to restart the preview). 49 | - **Preview any code**. Simple as that, select any code and preview it. No manual copy-pasting needed. 50 | - **With or without Terminal**. The extension parses the `manimgl` terminal output to provide rich VSCode integrations and makes possible an almost terminal-free workflow. 51 | - Shows the progress of the live Manim preview as VSCode progress bar. 52 | - Takes a user-defined delay into account, e.g. to wait for custom shell startup scripts (like `venv` activation). 53 | - State management: keeps track of the ManimGL state to react accordingly in different situations, e.g. prevent from running multiple statements at the same time. 54 | - **Video export**. Export your animations to a video file. A small wizard will guide you through the most important settings. 55 | - **And more...** Find all commands in the command palette (`Ctrl/Cmd + Shift + P`) by searching for `> Manim Notebook`. E.g. another command lets you `clear()` the window. Yet with another one you can start the scene at your cursor. 56 | 57 | 58 | In the long run, we aim to even provide a Debugger for Manim code such that you can step through your animations and run until breakpoints. This would be a huge step forward in the Manim development workflow. For now, the Manim Cells that we provide are a start towards a more interactive experience. 59 | 60 | 61 |
62 |
63 | 64 | 65 | Manim Notebook Logo with a fancy color-gradient background 66 | 67 | 68 | If you enjoy Manim, you might also like [Motion Canvas](https://motioncanvas.io/) by aarthificial. It even has its own editor integrated and uses HTML5 Canvas to render the animations. The learning curve is less steep and it's also very powerful. 69 | -------------------------------------------------------------------------------- /assets/manim-notebook-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Manim-Notebook/manim-notebook/398fdf835b5b17f7e1b1d33953056b4326a226c5/assets/manim-notebook-logo.png -------------------------------------------------------------------------------- /assets/walkthrough/manim-installation.md: -------------------------------------------------------------------------------- 1 |
2 | Manim Notebook Logo showing a physical notebook with an 'M' letter on its title page 5 | 6 |
7 |

8 | Welcome to Manim Notebook 9 |

10 |

11 | Your extension to interactively preview Manim animations 12 | | GitHub 13 |

14 |
15 |
16 | 17 |
18 | 19 | # 🎈 Manim Installation 20 | 21 | > **Manim Notebook only works with ManimGL by 3Blue1Brown,
NOT with the community edition of Manim (Manim CE)**. 22 | 23 | Unfortunately, installing 3Blue1Brown's Manim is not a trivial task due to its dependencies. Luckily, you have to do this only once. Get yourself some quiet time, and follow the steps below. 24 | 25 | - First, note that there is a [**Manim installation guide**](https://3b1b.github.io/manim/getting_started/installation.html) available. However, it mainly just tells you _what_ to install and not _how to_. 26 | - In the GitHub Actions pipeline for our Manim Notebook extension, we succeeded to automatically install ManimGL on the latest macOS, Ubuntu, and Windows. So take inspiration from there, i.e. this [`tests.yml`](https://github.com/Manim-Notebook/manim-notebook/blob/main/.github/workflows/tests.yml) workflow, as well as [this `manimInstaller.ts`](https://github.com/Manim-Notebook/manim-notebook/blob/main/tests/utils/manimInstaller.ts). It might be worth it to search for keywords there if you encounter errors during installation. 27 | 28 | ## 💨 Dependency installation guidance & quirks 29 | 30 | - Note that we don't install ffmpeg in these steps. ManimGL will work without it as well if you just want to preview animations (it will just show a warning, that's it). To finally render your animations to an `.mp4` file, you will need ffmpeg, though. 31 | - We also don't install LaTeX in these steps. If you want to use LaTeX in your animations, you will need to install LaTeX separately. 32 | 33 | **Python `3.13` (any OS)** 34 | 35 | ```py 36 | # https://github.com/jiaaro/pydub/issues/815 37 | pip install audioop-lts 38 | ``` 39 | 40 | **Linux (e.g. Ubuntu)** 41 | 42 | You probably need the OpenGL Mesa Utils. 43 | 44 | ```py 45 | # Install OpenGL Mesa Utils 46 | sudo add-apt-repository ppa:kisak/kisak-mesa 47 | sudo apt-get update 48 | sudo apt-get install mesa-utils -y 49 | 50 | # Install PyOpenGL 51 | pip install PyOpenGL 52 | 53 | # Test your OpenGL version via: 54 | xvfb-run -a glxinfo | grep "OpenGL version" 55 | 56 | # Install Pango 57 | sudo apt-get install libpango1.0-dev -y 58 | ``` 59 | 60 | Only apply those fixes in case you encounter the respective errors: 61 | 62 | ```py 63 | # Work around 'NoneType' object has no attribute 'glGetError' 64 | # https://github.com/MPI-IS/mesh/issues/23#issuecomment-607784234 65 | sudo apt-get install python3-opengl 66 | 67 | # Install copy-paste mechanism to avoid ClipboardUnavailable errors 68 | # (python pyperclip makes use of xclip on Linux) 69 | sudo apt-get install xclip -y 70 | ``` 71 | 72 | 73 | **Windows** 74 | 75 | Windows itself only comes with OpenGL 1.1, which is not enough for ManimGL. We found that the easiest way to do so is via [this amazing repo](https://github.com/pal1000/mesa-dist-win), which serves precompiled Mesa3D drivers for Windows. In a PowerShell, run: 76 | 77 | ```powershell 78 | # Install OpenGL pre-built Mesa binaries from mesa-dist-win 79 | Invoke-WebRequest -Uri https://github.com/pal1000/mesa-dist-win/releases/download/24.3.2/mesa3d-24.3.2-release-mingw.7z -OutFile mesa3d.7z 80 | 81 | # Extract (on purpose no space between -o and mesa3d) 82 | 7z x mesa3d.7z -omesa3d 83 | 84 | # Install system-wide (option 1: core desktop OpenGL drivers) 85 | .\mesa3d\systemwidedeploy.cmd 1 86 | ``` 87 | 88 | Test your OpenGL version: 89 | 90 | ```bash 91 | # Download wglinfo (analogous to glxinfo) 92 | curl -L -O https://github.com/gkv311/wglinfo/releases/latest/download/wglinfo64.exe 93 | chmod +x wglinfo64.exe 94 | ./wglinfo64.exe | grep "OpenGL version" 95 | ``` 96 | 97 | **macOS** 98 | 99 | Lucky you, macOS already came with everything that ManimGL needed out of the box in our tests. We don't know a way to retrieve the OpenGL version on macOS, if you happen to know one, please let us know [in a new issue](https://github.com/Manim-Notebook/manim-notebook/issues). 100 | 101 | ## 💨 ManimGL installation 102 | 103 | Finally, installing ManimGL should be as easy as installing any other Python package. However, we recommend installing it in an isolated environment to avoid conflicts with other packages. You can do so by creating a new virtual environment and installing ManimGL there. 104 | 105 | ```bash 106 | # Clone Manim anywhere you like. 107 | # Here, we assume you want to store it in `~/dev/manim/` 108 | git clone https://github.com/3b1b/manim.git ~/dev/manim/ 109 | 110 | # Change into the directory where you want to use Manim, 111 | # e.g. `~/dev/manim-videos/` 112 | # and create a new virtual environment there, 113 | # where you will install Manim and its Python dependencies. 114 | cd ~/dev/manim-videos/ 115 | python3 -m venv manim-venv 116 | . manim-venv/bin/activate # source the activate script 117 | 118 | # Now `pip --version` should show its version and a path in your `manim-venv/` 119 | # directory. That is, you are now in the virtual environment (venv) 120 | # Finally, install ManimGL and its Python dependencies into that venv. 121 | pip install -e ~/dev/manim/ 122 | ``` 123 | 124 | Note that via this technique, the `manimgl` command is only available in the virtual environment (which is actually a good thing due to the isolation). If you want to upgrade Manim, it's as easy as pulling the latest commit from the repo: `git pull` (inside the `~/dev/manim/` folder). You might have to re-run `pip install -e ~/dev/manim/` afterwards (inside the virtual environment (!)). Note that the VSCode Python extension by Microsoft will also detect that you use a virtual environment: upon opening a new terminal, it will automatically source the `activate` script for you. 125 | 126 | Finally, check your Manim version by running `manimgl --version`. If this shows the latest version, you have successfully installed ManimGL, congratulations! 🎉 This was the most difficult part, from now on it should be a breeze to use Manim. 127 | -------------------------------------------------------------------------------- /assets/walkthrough/sample_scene.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | # ❗ 4 | # ❗ First, make sure to save this file somewhere. Otherwise, nothing will work here. 5 | # ❗ 6 | 7 | 8 | class MyFirstManimNotebook(Scene): 9 | def construct(self): 10 | ## Your first Manim Cell 11 | # Note how a Manim Cell is introduced by a comment starting with `##`. 12 | # You should see a button `Preview Manim Cell` above this cell. 13 | # Click on it to preview the animation. 14 | circle = Circle() 15 | circle.set_stroke(BLUE_E, width=4) 16 | self.play(ShowCreation(circle)) 17 | 18 | ## Transform circle to square 19 | # You can also preview the cell with the default hotkey: 20 | # `Cmd+' Cmd+e` (MacOS) or `Ctrl+' Ctrl+e` (Windows/Linux) 21 | square = Square() 22 | self.play(ReplacementTransform(circle, square)) 23 | 24 | ## Make it red and fly away 25 | self.play( 26 | square.animate.set_fill(RED_D, opacity=0.5), 27 | self.frame.animate.set_width(25), 28 | ) 29 | 30 | # Now try to interact with the scene, e.g. press `d` and drag the mouse 31 | # (without any mouse buttons pressed) to rotate the camera. 32 | 33 | # Check out the Manim Quickstart Guide for more tips: 34 | # https://3b1b.github.io/manim/getting_started/quickstart.html 35 | 36 | # Many more example scenes can be found here: 37 | # https://3b1b.github.io/manim/getting_started/example_scenes.html 38 | 39 | # 🌟 Last but not least: if you like this extension, give it a star on 40 | # GitHub. That would mean a lot to us :) Feel free to also let us know 41 | # what we can improve. Happy Manim Animation! 🌈 42 | # https://github.com/Manim-Notebook/manim-notebook 43 | -------------------------------------------------------------------------------- /assets/walkthrough/wiki.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | // adapted from https://code.visualstudio.com/api/working-with-extensions/bundling-extension#run-esbuild 2 | const esbuild = require("esbuild"); 3 | 4 | const production = process.argv.includes("--production"); 5 | const watch = process.argv.includes("--watch"); 6 | 7 | async function main() { 8 | const ctx = await esbuild.context({ 9 | entryPoints: ["src/extension.ts"], 10 | bundle: true, 11 | format: "cjs", 12 | minify: production, 13 | sourcemap: true, 14 | sourcesContent: false, 15 | platform: "node", 16 | outfile: "dist/extension.js", 17 | external: ["vscode"], 18 | logLevel: "warning", 19 | plugins: [ 20 | /* add to the end of plugins array */ 21 | esbuildProblemMatcherPlugin, 22 | ], 23 | }); 24 | if (watch) { 25 | await ctx.watch(); 26 | } else { 27 | await ctx.rebuild(); 28 | await ctx.dispose(); 29 | } 30 | } 31 | 32 | /** 33 | * @type {import('esbuild').Plugin} 34 | */ 35 | const esbuildProblemMatcherPlugin = { 36 | name: "esbuild-problem-matcher", 37 | 38 | setup(build) { 39 | build.onStart(() => { 40 | console.log("[watch] build started"); 41 | }); 42 | build.onEnd((result) => { 43 | result.errors.forEach(({ text, location }) => { 44 | console.error(`❌ [ERROR] ${text}`); 45 | if (location == null) return; 46 | console.error(` ${location.file}:${location.line}:${location.column}:`); 47 | }); 48 | console.log("[watch] build finished"); 49 | }); 50 | }, 51 | }; 52 | 53 | main().catch((e) => { 54 | console.error(e); 55 | process.exit(1); 56 | }); 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "manim-notebook", 3 | "displayName": "Manim Notebook", 4 | "description": "ManimGL with interactive previewing to easily visualize and share the beauty of mathematics and related fields.", 5 | "version": "1.0.1", 6 | "publisher": "Manim-Notebook", 7 | "author": { 8 | "name": "Manim Notebook Contributors" 9 | }, 10 | "license": "SEE LICENSE IN LICENSE.txt", 11 | "homepage": "https://github.com/Manim-Notebook/manim-notebook", 12 | "bugs": "https://github.com/Manim-Notebook/manim-notebook/issues", 13 | "keywords": [ 14 | "Manim", 15 | "ManimGL", 16 | "3Blue1Brown", 17 | "notebook", 18 | "animation" 19 | ], 20 | "engines": { 21 | "vscode": "^1.93.0" 22 | }, 23 | "categories": [ 24 | "Visualization", 25 | "Notebooks", 26 | "Education" 27 | ], 28 | "qna": false, 29 | "activationEvents": [ 30 | "onLanguage:python" 31 | ], 32 | "main": "./dist/extension.js", 33 | "contributes": { 34 | "commands": [ 35 | { 36 | "command": "manim-notebook.previewManimCell", 37 | "title": "Preview active Manim Cell", 38 | "category": "Manim Notebook" 39 | }, 40 | { 41 | "command": "manim-notebook.reloadAndPreviewManimCell", 42 | "title": "Reload and Preview active Manim Cell", 43 | "category": "Manim Notebook" 44 | }, 45 | { 46 | "command": "manim-notebook.previewSelection", 47 | "title": "Preview selected Manim code", 48 | "category": "Manim Notebook" 49 | }, 50 | { 51 | "command": "manim-notebook.startScene", 52 | "title": "Start scene at cursor", 53 | "category": "Manim Notebook" 54 | }, 55 | { 56 | "command": "manim-notebook.exitScene", 57 | "title": "Quit preview", 58 | "category": "Manim Notebook" 59 | }, 60 | { 61 | "command": "manim-notebook.clearScene", 62 | "title": "Remove all objects from scene", 63 | "category": "Manim Notebook" 64 | }, 65 | { 66 | "command": "manim-notebook.recordLogFile", 67 | "title": "Record Log File", 68 | "category": "Manim Notebook" 69 | }, 70 | { 71 | "command": "manim-notebook.openWalkthrough", 72 | "title": "Open Walkthrough", 73 | "category": "Manim Notebook" 74 | }, 75 | { 76 | "command": "manim-notebook.exportScene", 77 | "title": "Export current scene as CLI command", 78 | "category": "Manim Notebook" 79 | }, 80 | { 81 | "command": "manim-notebook.redetectManimVersion", 82 | "title": "Redetect Manim version", 83 | "category": "Manim Notebook" 84 | } 85 | ], 86 | "keybindings": [ 87 | { 88 | "command": "manim-notebook.previewManimCell", 89 | "key": "ctrl+' ctrl+e", 90 | "mac": "cmd+' cmd+e", 91 | "when": "(editorTextFocus || terminalFocus) && editorLangId == 'python'" 92 | }, 93 | { 94 | "command": "manim-notebook.previewSelection", 95 | "key": "ctrl+' ctrl+r", 96 | "mac": "cmd+' cmd+r", 97 | "when": "(editorTextFocus || terminalFocus) && editorLangId == 'python'" 98 | }, 99 | { 100 | "command": "manim-notebook.startScene", 101 | "key": "ctrl+' ctrl+s", 102 | "mac": "cmd+' cmd+s", 103 | "when": "(editorTextFocus || terminalFocus) && editorLangId == 'python'" 104 | }, 105 | { 106 | "command": "manim-notebook.exitScene", 107 | "key": "ctrl+' ctrl+w", 108 | "mac": "cmd+' cmd+w" 109 | }, 110 | { 111 | "command": "manim-notebook.clearScene", 112 | "key": "ctrl+' ctrl+c", 113 | "mac": "cmd+' cmd+c" 114 | } 115 | ], 116 | "configuration": { 117 | "title": "Manim Notebook", 118 | "properties": { 119 | "manim-notebook.autoreload": { 120 | "type": "boolean", 121 | "default": "true", 122 | "markdownDescription": "If enabled, Manim will be startup with the `--autoreload` flag, such that your imported Python modules are reloaded automatically in the background. For this feature, ManimGL 1.7.2 or newer is required." 123 | }, 124 | "manim-notebook.clipboardTimeout": { 125 | "type": "number", 126 | "default": 650, 127 | "markdownDescription": "Configures the number of milliseconds (ms) to wait before your clipboard is restored. (Your clipboard is used to temporarily copy the selected code to be accessible by Manim)." 128 | }, 129 | "manim-notebook.confirmKillingActiveSceneToStartNewOne": { 130 | "type": "boolean", 131 | "default": true, 132 | "markdownDescription": "If enabled, you will be prompted to confirm killing an old session when you want to start a new scene at the cursor while an active scene is running." 133 | }, 134 | "manim-notebook.delayNewTerminal": { 135 | "type": "number", 136 | "default": 0, 137 | "markdownDescription": "Number of milliseconds (ms) to wait before executing any command in a newly opened terminal. This is useful when you have custom terminal startup commands that need to be executed before running Manim, e.g. a virtual environment activation (Python venv)." 138 | }, 139 | "manim-notebook.showCellBorders": { 140 | "type": "boolean", 141 | "default": true, 142 | "markdownDescription": "If enabled, Manim cells will have a border around them (top and bottom)." 143 | }, 144 | "manim-notebook.typesetStartCommentInBold": { 145 | "type": "boolean", 146 | "default": true, 147 | "markdownDescription": "If enabled, the start comment of each cell (comment that begins with `##`) will be typeset in bold." 148 | } 149 | } 150 | }, 151 | "colors": [ 152 | { 153 | "id": "manimNotebookColors.baseColor", 154 | "description": "Base color used for the Manim cell border and the respective start comment (comment that begins with `##`).", 155 | "defaults": { 156 | "dark": "#64A4ED", 157 | "light": "#2B7BD6", 158 | "highContrast": "#75B6FF", 159 | "highContrastLight": "#216CC2" 160 | } 161 | }, 162 | { 163 | "id": "manimNotebookColors.unfocused", 164 | "description": "Color used for the Manim cell border when the cell is not focused.", 165 | "defaults": { 166 | "dark": "#39506B", 167 | "light": "#DCE9F7", 168 | "highContrast": "#3C5878", 169 | "highContrastLight": "#C3DDF7" 170 | } 171 | } 172 | ], 173 | "walkthroughs": [ 174 | { 175 | "id": "manim-notebook-walkthrough", 176 | "title": "Manim Notebook", 177 | "description": "Welcome 👋 Get to know how to make the best out of Manim Notebook.", 178 | "steps": [ 179 | { 180 | "id": "checkManimInstallation", 181 | "title": "Check Manim Installation", 182 | "description": "Let's see if you have installed ManimGL.\n[Check Manim version](command:manim-notebook.redetectManimVersion)\n[Open installation guide](https://3b1b.github.io/manim/getting_started/installation.html)", 183 | "media": { 184 | "markdown": "assets/walkthrough/manim-installation.md" 185 | }, 186 | "completionEvents": [ 187 | "onCommand:manim-notebook.redetectManimVersion" 188 | ] 189 | }, 190 | { 191 | "id": "startExample", 192 | "title": "Start with an example", 193 | "description": "Open an example file to see how Manim Notebook works.\n[Open Sample](command:manim-notebook-walkthrough.openSample)", 194 | "media": { 195 | "svg": "assets/walkthrough/preview-cell.svg", 196 | "altText": "Preview Manim Cell" 197 | }, 198 | "completionEvents": [ 199 | "onCommand:manim-notebook-walkthrough.openSample" 200 | ] 201 | }, 202 | { 203 | "id": "showAllAvailableCommands", 204 | "title": "Show all available commands", 205 | "description": "Get a list of all available commands in Manim Notebook.\n[Show Commands](command:manim-notebook-walkthrough.showCommands)", 206 | "media": { 207 | "svg": "assets/walkthrough/commands.svg", 208 | "altText": "Manim Notebook commands" 209 | }, 210 | "completionEvents": [ 211 | "onCommand:manim-notebook-walkthrough.showCommands" 212 | ] 213 | }, 214 | { 215 | "id": "showKeyboardShortcuts", 216 | "title": "Show keyboard shortcuts", 217 | "description": "See all available keyboard shortcuts in Manim Notebook and modify them to whatever you like.\n[Show Shortcuts](command:manim-notebook-walkthrough.showShortcuts)", 218 | "media": { 219 | "svg": "assets/walkthrough/shortcuts.svg", 220 | "altText": "Manim Notebook keyboard shortcuts" 221 | }, 222 | "completionEvents": [ 223 | "onCommand:manim-notebook-walkthrough.showShortcuts" 224 | ] 225 | }, 226 | { 227 | "id": "showSettings", 228 | "title": "Show settings", 229 | "description": "Customize your Manim Notebook experience by changing settings.\n[Show Settings](command:manim-notebook-walkthrough.showSettings)", 230 | "media": { 231 | "svg": "assets/walkthrough/settings.svg", 232 | "altText": "Manim Notebook settings" 233 | }, 234 | "completionEvents": [ 235 | "onCommand:manim-notebook-walkthrough.showSettings" 236 | ] 237 | }, 238 | { 239 | "id": "openWiki", 240 | "title": "Open Wiki", 241 | "description": "Learn more about Manim Notebook on the GitHub Wiki.\n[Open Wiki](command:manim-notebook-walkthrough.openWiki)", 242 | "media": { 243 | "svg": "assets/walkthrough/wiki.svg", 244 | "altText": "Manim Notebook Wiki" 245 | }, 246 | "completionEvents": [ 247 | "onCommand:manim-notebook-walkthrough.openWiki" 248 | ] 249 | } 250 | ] 251 | } 252 | ] 253 | }, 254 | "scripts": { 255 | "check-types": "tsc --noEmit --project .config/tsconfig.json", 256 | "watch:esbuild": "node esbuild.js --watch", 257 | "watch:tsc": "tsc --noEmit --watch --project .config/tsconfig.json", 258 | "watch:tests": "tsc --watch --outDir out-test --project .config/tsconfig.tests.json", 259 | "package-dev": "npm run check-types && node esbuild.js", 260 | "package": "npm run check-types && node esbuild.js --production", 261 | "package-vsce": "vsce package", 262 | "vscode:prepublish": "npm run package", 263 | "deploy": "vsce publish", 264 | "lint": "eslint src --config ./.config/eslint.mjs", 265 | "install-manim": "node out-test/tests/utils/installManim.js", 266 | "compile-tests": "tsc --outDir out-test --project .config/tsconfig.tests.json", 267 | "pretest": "node tests/scripts/setTestEntryPoint.js set && npm run package-dev && npm run compile-tests && npm run install-manim", 268 | "test": "node out-test/tests/main.js", 269 | "test-without-pre-or-posttest": "node out-test/tests/main.js", 270 | "posttest": "node tests/scripts/setTestEntryPoint.js reset" 271 | }, 272 | "devDependencies": { 273 | "@stylistic/eslint-plugin": "^2.12.1", 274 | "@types/chai": "^5.0.1", 275 | "@types/node": "^22.10.2", 276 | "@types/sinon": "^17.0.3", 277 | "@types/vscode": "1.93.0", 278 | "@typescript-eslint/parser": "^8.18.2", 279 | "@vscode/test-cli": "^0.0.10", 280 | "@vscode/test-electron": "^2.4.1", 281 | "@vscode/vsce": "^3.2.2", 282 | "chai": "^5.1.2", 283 | "esbuild": "^0.25.0", 284 | "eslint": "^9.17.0", 285 | "glob": "^11.0.0", 286 | "globals": "^15.14.0", 287 | "mocha": "^11.0.1", 288 | "sinon": "^19.0.2", 289 | "source-map-support": "^0.5.21", 290 | "typescript": "^5.7.2" 291 | }, 292 | "repository": { 293 | "type": "git", 294 | "url": "https://github.com/Manim-Notebook/manim-notebook.git" 295 | }, 296 | "icon": "assets/manim-notebook-logo.png", 297 | "galleryBanner": { 298 | "color": "#112039", 299 | "theme": "dark" 300 | } 301 | } -------------------------------------------------------------------------------- /src/export.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { CancellationToken, CodeLens, TextDocument, window } from "vscode"; 3 | import { Logger, Window } from "./logger"; 4 | import { ManimClass } from "./pythonParsing"; 5 | import { 6 | MultiStepInput, 7 | shouldResumeNoOp, 8 | toQuickPickItems, 9 | } from "./utils/multiStepQuickPickUtil"; 10 | import { waitNewTerminalDelay } from "./utils/terminal"; 11 | 12 | class VideoQuality { 13 | static readonly LOW = new VideoQuality("Low Quality (480p)", "--low_quality"); 14 | static readonly MEDIUM = new VideoQuality("Medium Quality (720p)", "--medium_quality"); 15 | static readonly HIGH = new VideoQuality("High Quality (1080p)", "--hd"); 16 | static readonly VERY_HIGH = new VideoQuality("Very High Quality (4K)", "--uhd"); 17 | 18 | // eslint-disable-next-line no-unused-vars 19 | private constructor(public readonly name: string, public readonly cliFlag: string) { } 20 | 21 | /** 22 | * Returns the names of all VideoQuality objects. 23 | */ 24 | static names(): string[] { 25 | return Object.values(VideoQuality).map(quality => quality.name); 26 | } 27 | 28 | /** 29 | * Returns the VideoQuality object that has the given name. 30 | */ 31 | static fromName(name: string): VideoQuality { 32 | return Object.values(VideoQuality).find(quality => quality.name === name); 33 | } 34 | } 35 | 36 | interface VideoSettings { 37 | sceneName: string; 38 | quality: string; 39 | fps: string; 40 | fileName: string; 41 | folderPath: string; 42 | } 43 | 44 | /** 45 | * Guides the user through a multi-step wizard to configure the export options 46 | * for a scene, i.e. quality, fps, filename, and folder path. The final command 47 | * is then pasted to a new terminal where the user can run it. 48 | * 49 | * @param sceneName The name of the Manim scene to export. Optional if called 50 | * from the command palette. Not optional if called from a CodeLens. 51 | */ 52 | export async function exportScene(sceneName?: string) { 53 | await vscode.commands.executeCommand("workbench.action.files.save"); 54 | 55 | // Command called via command palette 56 | if (sceneName === undefined) { 57 | const editor = window.activeTextEditor; 58 | if (!editor) { 59 | return Window.showErrorMessage( 60 | "No opened file found. Please place your cursor in a Manim scene."); 61 | } 62 | 63 | const sceneClassLine = ManimClass 64 | .getManimClassAtCursor(editor.document, editor.selection.start.line); 65 | if (!sceneClassLine) { 66 | return Window.showErrorMessage("Place your cursor in a Manim scene."); 67 | } 68 | 69 | sceneName = sceneClassLine.className; 70 | } 71 | 72 | const QUICK_PICK_TITLE = "Export scene as video"; 73 | 74 | /** 75 | * Lets the user pick the quality of the video to export. 76 | */ 77 | async function pickQuality(input: MultiStepInput, state: Partial) { 78 | const qualityPick = await input.showQuickPick({ 79 | title: QUICK_PICK_TITLE, 80 | step: 1, 81 | totalSteps: 3, 82 | placeholder: "Select the quality of the video to export", 83 | items: toQuickPickItems(VideoQuality.names()), 84 | shouldResume: shouldResumeNoOp, 85 | }); 86 | state.quality = VideoQuality.fromName(qualityPick.label).cliFlag; 87 | 88 | return (input: MultiStepInput) => pickFps(input, state); 89 | } 90 | 91 | /** 92 | * Lets the user pick the frames per second (fps) of the video to export. 93 | */ 94 | async function pickFps(input: MultiStepInput, state: Partial) { 95 | const fps = await input.showInputBox({ 96 | title: QUICK_PICK_TITLE, 97 | step: 2, 98 | totalSteps: 3, 99 | placeholder: "fps", 100 | prompt: "Frames per second (fps) of the video", 101 | value: typeof state.fps === "string" ? state.fps : "30", 102 | validate: async (input: string) => { 103 | const fps = Number(input); 104 | if (isNaN(fps) || fps <= 0) { 105 | return "Please enter a positive number."; 106 | } 107 | if (input.includes(".")) { 108 | return "Please enter an integer number."; 109 | } 110 | return undefined; 111 | }, 112 | shouldResume: shouldResumeNoOp, 113 | }); 114 | state.fps = fps; 115 | 116 | return (input: MultiStepInput) => pickFileName(input, state); 117 | } 118 | 119 | /** 120 | * Lets the user pick the filename of the video to export. The default value 121 | * is the name of the scene followed by ".mp4". 122 | * 123 | * It is ok to not append `.mp4` here as Manim will also do it if it is not 124 | * present in the filename. 125 | */ 126 | async function pickFileName(input: MultiStepInput, state: Partial) { 127 | const fileName = await input.showInputBox({ 128 | title: QUICK_PICK_TITLE, 129 | step: 3, 130 | totalSteps: 3, 131 | placeholder: `${sceneName}.mp4`, 132 | prompt: "Filename of the video", 133 | value: state.fileName ? state.fileName : `${sceneName}.mp4`, 134 | validate: async (input: string) => { 135 | if (!input) { 136 | return "Please enter a filename."; 137 | } 138 | if (/[/\\]/g.test(input)) { 139 | return "Please don't use slashes." 140 | + " You can specify the folder in the next step."; 141 | } 142 | if (/[~`@!#$§%\^&*+=\[\]';,{}|":<>\?]/g.test(input)) { 143 | return "Please don't use special characters in the filename."; 144 | } 145 | if (input.endsWith(".")) { 146 | return "Please don't end the filename with a dot."; 147 | } 148 | return undefined; 149 | }, 150 | shouldResume: shouldResumeNoOp, 151 | }); 152 | state.fileName = fileName; 153 | 154 | return (input: MultiStepInput) => pickFileLocation(input, state); 155 | } 156 | 157 | /** 158 | * Lets the user pick the folder location where the video should be saved. 159 | */ 160 | async function pickFileLocation(input: MultiStepInput, state: Partial) { 161 | const folderUri = await window.showOpenDialog({ 162 | canSelectFiles: false, 163 | canSelectFolders: true, 164 | canSelectMany: false, 165 | openLabel: "Select folder", 166 | title: "Select folder to save the video to", 167 | }); 168 | if (folderUri) { 169 | state.folderPath = folderUri[0].fsPath; 170 | } 171 | } 172 | 173 | /** 174 | * Initiates the multi-step wizard and returns the collected inputs 175 | * from the user. 176 | */ 177 | async function collectInputs(): Promise { 178 | const state = {} as Partial; 179 | state.sceneName = sceneName; 180 | await MultiStepInput.run((input: MultiStepInput) => pickQuality(input, state)); 181 | return state as VideoSettings; 182 | } 183 | 184 | const settings = await collectInputs(); 185 | if (!settings.quality || !settings.fps || !settings.fileName || !settings.folderPath) { 186 | Logger.debug("⭕ Export scene cancelled since not all settings were provided"); 187 | return; 188 | } 189 | 190 | const editor = window.activeTextEditor; 191 | if (!editor) { 192 | return Window.showErrorMessage( 193 | "No opened file found. Please place your cursor at a line of code."); 194 | } 195 | 196 | const exportCommand = toManimExportCommand(settings, editor); 197 | 198 | await vscode.env.clipboard.writeText(exportCommand); 199 | const terminal = window.createTerminal("Manim Export"); 200 | terminal.show(); 201 | await waitNewTerminalDelay(); 202 | terminal.sendText(exportCommand, false); 203 | Window.showInformationMessage("Export command pasted to terminal and clipboard."); 204 | } 205 | 206 | /** 207 | * Converts the given VideoSettings object into a Manim export command. 208 | * 209 | * See the Manim documentation for all supported flags: 210 | * https://3b1b.github.io/manim/getting_started/configuration.html#all-supported-flags 211 | * 212 | * @param settings The settings defined via the multi-step wizard. 213 | * @param editor The active text editor. 214 | * @returns The Manim export command as a string. 215 | */ 216 | function toManimExportCommand(settings: VideoSettings, editor: vscode.TextEditor): string { 217 | const cmds = [ 218 | "manimgl", `"${editor.document.fileName}"`, settings.sceneName, 219 | "-w", settings.quality, `--fps ${settings.fps}`, 220 | `--video_dir "${settings.folderPath}"`, 221 | `--file_name "${settings.fileName}"`, 222 | ]; 223 | return cmds.join(" "); 224 | } 225 | 226 | /** 227 | * A CodeLens provider that adds a `Export Scene` CodeLens to each Python 228 | * class definition line in the active document. 229 | */ 230 | export class ExportSceneCodeLens implements vscode.CodeLensProvider { 231 | public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] { 232 | const codeLenses: vscode.CodeLens[] = []; 233 | 234 | for (const classLine of ManimClass.findAllIn(document)) { 235 | const range = new vscode.Range(classLine.lineNumber, 0, classLine.lineNumber, 0); 236 | 237 | codeLenses.push(new vscode.CodeLens(range, { 238 | title: "🎞️ Export Manim scene", 239 | command: "manim-notebook.exportScene", 240 | tooltip: "Generate a command to export this scene as a video", 241 | arguments: [classLine.className], 242 | })); 243 | } 244 | 245 | return codeLenses; 246 | } 247 | 248 | public resolveCodeLens(codeLens: CodeLens, _token: CancellationToken): CodeLens { 249 | return codeLens; 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import * as vscode from "vscode"; 3 | import { window } from "vscode"; 4 | import { exportScene, ExportSceneCodeLens } from "./export"; 5 | import { Logger, LogRecorder, Window } from "./logger"; 6 | import { ManimCell } from "./manimCell"; 7 | import { ManimShell, NoActiveShellError } from "./manimShell"; 8 | import { determineManimVersion } from "./manimVersion"; 9 | import { applyWindowsPastePatch } from "./patches/applyPatches"; 10 | import { previewCode, previewManimCell, reloadAndPreviewManimCell } from "./previewCode"; 11 | import { exitScene, startScene } from "./startStopScene"; 12 | import { setupTestEnvironment } from "./utils/testing"; 13 | import { getBinaryPathInPythonEnv } from "./utils/venv"; 14 | import { registerWalkthroughCommands } from "./walkthrough"; 15 | 16 | export let manimNotebookContext: vscode.ExtensionContext; 17 | 18 | export async function activate(context: vscode.ExtensionContext) { 19 | if (process.env.IS_TESTING === "true") { 20 | console.log("💠 Setting up test environment"); 21 | Logger.info("💠 Setting up test environment"); 22 | setupTestEnvironment(); 23 | } else { 24 | console.log("💠 Not setting up test environment"); 25 | } 26 | 27 | manimNotebookContext = context; 28 | 29 | // Trigger the Manim shell to start listening to the terminal 30 | ManimShell.instance; 31 | 32 | // Register the open walkthrough command earlier, so that it can be used 33 | // even while other activation tasks are still running 34 | const openWalkthroughCommand = vscode.commands.registerCommand( 35 | "manim-notebook.openWalkthrough", async () => { 36 | Logger.info("💠 Command requested: Open Walkthrough"); 37 | await vscode.commands.executeCommand("workbench.action.openWalkthrough", 38 | `${context.extension.id}#manim-notebook-walkthrough`, false); 39 | }); 40 | context.subscriptions.push(openWalkthroughCommand); 41 | 42 | let pythonBinary: string; 43 | try { 44 | waitForPythonExtension().then((pythonEnvPath: string | undefined) => { 45 | // (These tasks here can be performed in the background) 46 | 47 | // also see https://github.com/Manim-Notebook/manim-notebook/pull/117#discussion_r1932764875 48 | const pythonBin = process.platform === "win32" ? "python" : "python3"; 49 | pythonBinary = pythonEnvPath 50 | ? getBinaryPathInPythonEnv(pythonEnvPath, pythonBin) 51 | : pythonBin; 52 | 53 | if (process.platform === "win32") { 54 | applyWindowsPastePatch(context, pythonBinary); 55 | } 56 | 57 | determineManimVersion(pythonBinary); 58 | }); 59 | } catch (err) { 60 | Logger.error("Error in background activation processing" 61 | + ` (python extension waiting, windows paste patch or manim version check): ${err}`); 62 | } 63 | 64 | const previewManimCellCommand = vscode.commands.registerCommand( 65 | "manim-notebook.previewManimCell", (cellCode?: string, startLine?: number) => { 66 | Logger.info(`💠 Command requested: Preview Manim Cell, startLine=${startLine}`); 67 | previewManimCell(cellCode, startLine); 68 | }); 69 | 70 | const reloadAndPreviewManimCellCommand = vscode.commands.registerCommand( 71 | "manim-notebook.reloadAndPreviewManimCell", 72 | (cellCode?: string, startLine?: number) => { 73 | Logger.info("💠 Command requested: Reload & Preview Manim Cell" 74 | + `, startLine=${startLine}`); 75 | reloadAndPreviewManimCell(cellCode, startLine); 76 | }); 77 | 78 | const previewSelectionCommand = vscode.commands.registerCommand( 79 | "manim-notebook.previewSelection", () => { 80 | Logger.info("💠 Command requested: Preview Selection"); 81 | previewSelection(); 82 | }, 83 | ); 84 | 85 | const startSceneCommand = vscode.commands.registerCommand( 86 | "manim-notebook.startScene", () => { 87 | Logger.info("💠 Command requested: Start Scene"); 88 | startScene(); 89 | }, 90 | ); 91 | 92 | const exitSceneCommand = vscode.commands.registerCommand( 93 | "manim-notebook.exitScene", () => { 94 | Logger.info("💠 Command requested: Exit Scene"); 95 | exitScene(); 96 | }, 97 | ); 98 | 99 | const clearSceneCommand = vscode.commands.registerCommand( 100 | "manim-notebook.clearScene", () => { 101 | Logger.info("💠 Command requested: Clear Scene"); 102 | clearScene(); 103 | }, 104 | ); 105 | 106 | const recordLogFileCommand = vscode.commands.registerCommand( 107 | "manim-notebook.recordLogFile", async () => { 108 | Logger.info("💠 Command requested: Record Log File"); 109 | await LogRecorder.recordLogFile(context); 110 | }); 111 | 112 | const exportSceneCommand = vscode.commands.registerCommand( 113 | "manim-notebook.exportScene", async (sceneName?: string) => { 114 | Logger.info("💠 Command requested: Export Scene"); 115 | await exportScene(sceneName); 116 | }); 117 | context.subscriptions.push( 118 | vscode.languages.registerCodeLensProvider( 119 | { language: "python" }, new ExportSceneCodeLens()), 120 | ); 121 | 122 | // internal command 123 | const finishRecordingLogFileCommand = vscode.commands.registerCommand( 124 | "manim-notebook.finishRecordingLogFile", async () => { 125 | Logger.info("💠 Command requested: Finish Recording Log File"); 126 | await LogRecorder.finishRecordingLogFile(context); 127 | }); 128 | 129 | const redetectManimVersionCommand = vscode.commands.registerCommand( 130 | "manim-notebook.redetectManimVersion", async () => { 131 | Logger.info("💠 Command requested: Redetect Manim Version"); 132 | if (!pythonBinary) { 133 | Window.showWarningMessage("Please wait for Manim Notebook to have finished activating."); 134 | return; 135 | } 136 | await determineManimVersion(pythonBinary); 137 | }); 138 | 139 | registerWalkthroughCommands(context); 140 | 141 | context.subscriptions.push( 142 | previewManimCellCommand, 143 | previewSelectionCommand, 144 | reloadAndPreviewManimCellCommand, 145 | startSceneCommand, 146 | exitSceneCommand, 147 | clearSceneCommand, 148 | recordLogFileCommand, 149 | exportSceneCommand, 150 | finishRecordingLogFileCommand, 151 | redetectManimVersionCommand, 152 | ); 153 | registerManimCellProviders(context); 154 | 155 | if (process.env.IS_TESTING === "true") { 156 | console.log("💠 Extension marked as activated"); 157 | activatedEmitter.emit("activated"); 158 | } 159 | } 160 | 161 | /** 162 | * Waits for the Microsoft Python extension to be activated, in case it is 163 | * installed. 164 | * 165 | * @returns The path to the Python environment, if it is available. 166 | */ 167 | async function waitForPythonExtension(): Promise { 168 | const pythonExtension = vscode.extensions.getExtension("ms-python.python"); 169 | if (!pythonExtension) { 170 | Logger.info("💠 Python extension not installed"); 171 | return; 172 | } 173 | 174 | // Waiting for Python extension 175 | const pythonApi = await pythonExtension.activate(); 176 | Logger.info("💠 Python extension activated"); 177 | 178 | // Path to venv 179 | const environmentPath = pythonApi.environments.getActiveEnvironmentPath(); 180 | if (!environmentPath) { 181 | Logger.debug("No active environment path found"); 182 | return; 183 | } 184 | const environment = await pythonApi.environments.resolveEnvironment(environmentPath); 185 | if (!environment) { 186 | Logger.debug("Environment could not be resolved"); 187 | return; 188 | } 189 | 190 | return environment.path; 191 | } 192 | 193 | /** 194 | * A global event emitter that can be used to listen for the extension being 195 | * activated. This is only used for testing purposes. 196 | */ 197 | class GlobalEventEmitter extends EventEmitter {} 198 | export const activatedEmitter = new GlobalEventEmitter(); 199 | 200 | export function deactivate() { 201 | Logger.deactivate(); 202 | Logger.info("💠 Manim Notebook extension deactivated"); 203 | } 204 | 205 | /** 206 | * Previews the selected code. 207 | * 208 | * - both ends of the selection automatically extend to the start & end of lines 209 | * (for convenience) 210 | * - if Multi-Cursor selection: 211 | * only the first selection is considered 212 | * (TODO: make all selections be considered - expand at each selection) 213 | * 214 | * If Manim isn't running, it will be automatically started 215 | * (before the first selected line). 216 | */ 217 | async function previewSelection() { 218 | const editor = window.activeTextEditor; 219 | if (!editor) { 220 | Window.showErrorMessage("Select some code to preview."); 221 | return; 222 | } 223 | 224 | let selectedText; 225 | const selection = editor.selection; 226 | if (selection.isEmpty) { 227 | // If nothing is selected - select the whole line 228 | const line = editor.document.lineAt(selection.start.line); 229 | selectedText = editor.document.getText(line.range); 230 | } else { 231 | // If selected - extend selection to start and end of lines 232 | const range = new vscode.Range( 233 | editor.document.lineAt(selection.start.line).range.start, 234 | editor.document.lineAt(selection.end.line).range.end, 235 | ); 236 | selectedText = editor.document.getText(range); 237 | } 238 | 239 | if (!selectedText) { 240 | Window.showErrorMessage("Select some code to preview."); 241 | return; 242 | } 243 | 244 | await previewCode(selectedText, selection.start.line); 245 | } 246 | 247 | /** 248 | * Runs the `clear()` command in the terminal - 249 | * removes all objects from the scene. 250 | */ 251 | async function clearScene() { 252 | try { 253 | await ManimShell.instance.executeIPythonCommandExpectSession("clear()"); 254 | } catch (error) { 255 | if (error instanceof NoActiveShellError) { 256 | Window.showWarningMessage("No active Manim session found to remove objects from."); 257 | return; 258 | } 259 | Logger.error(`💥 Error while trying to remove objects from scene: ${error}`); 260 | throw error; 261 | } 262 | } 263 | 264 | /** 265 | * Registers the Manim cell "providers", e.g. code lenses and folding ranges. 266 | */ 267 | function registerManimCellProviders(context: vscode.ExtensionContext) { 268 | const manimCell = new ManimCell(); 269 | 270 | const codeLensProvider = vscode.languages.registerCodeLensProvider( 271 | { language: "python" }, manimCell); 272 | const foldingRangeProvider = vscode.languages.registerFoldingRangeProvider( 273 | { language: "python" }, manimCell); 274 | context.subscriptions.push(codeLensProvider, foldingRangeProvider); 275 | 276 | window.onDidChangeActiveTextEditor((editor) => { 277 | if (editor) { 278 | manimCell.applyCellDecorations(editor); 279 | } 280 | }, null, context.subscriptions); 281 | 282 | vscode.workspace.onDidChangeTextDocument((event) => { 283 | const editor = window.activeTextEditor; 284 | if (editor && event.document === editor.document) { 285 | manimCell.applyCellDecorations(editor); 286 | } 287 | }, null, context.subscriptions); 288 | 289 | window.onDidChangeTextEditorSelection((event) => { 290 | manimCell.applyCellDecorations(event.textEditor); 291 | }, null, context.subscriptions); 292 | 293 | if (window.activeTextEditor) { 294 | manimCell.applyCellDecorations(window.activeTextEditor); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as os from "os"; 3 | import * as path from "path"; 4 | import * as vscode from "vscode"; 5 | import { LogOutputChannel, window } from "vscode"; 6 | import { revealFileInOS, waitUntilFileExists } from "./utils/fileUtil"; 7 | 8 | const LOGGER_NAME = "Manim Notebook"; 9 | 10 | export class Logger { 11 | public static isRecording = false; 12 | 13 | private static logger: LogOutputChannel = window.createOutputChannel( 14 | LOGGER_NAME, { log: true }); 15 | 16 | public static trace(message: string) { 17 | if (!this.isRecording) { 18 | return; 19 | } 20 | this.logger.trace(`${Logger.getFormattedCallerInformation()} ${message}`); 21 | } 22 | 23 | public static debug(message: string) { 24 | if (!this.isRecording) { 25 | return; 26 | } 27 | this.logger.debug(`${Logger.getFormattedCallerInformation()} ${message}`); 28 | } 29 | 30 | public static info(message: string) { 31 | if (!this.isRecording) { 32 | return; 33 | } 34 | this.logger.info(`${Logger.getFormattedCallerInformation()} ${message}`); 35 | } 36 | 37 | public static warn(message: string) { 38 | if (!this.isRecording) { 39 | return; 40 | } 41 | this.logger.warn(`${Logger.getFormattedCallerInformation()} ${message}`); 42 | } 43 | 44 | public static error(message: string) { 45 | if (!this.isRecording) { 46 | return; 47 | } 48 | this.logger.error(`${Logger.getFormattedCallerInformation()} ${message}`); 49 | } 50 | 51 | public static deactivate() { 52 | this.logger.dispose(); 53 | } 54 | 55 | /** 56 | * Clears the output panel and the log file. This is necessary since clearing 57 | * is not performed automatically on MacOS. See issue #58. 58 | * 59 | * @param logFilePath The URI of the log file. 60 | */ 61 | public static async clear(logFilePath: vscode.Uri) { 62 | // This logging statement here is important to ensure that something 63 | // is written to the log file, such that the file is created on disk. 64 | Logger.info("📜 Trying to clear logfile..."); 65 | 66 | this.logger.clear(); 67 | 68 | await waitUntilFileExists(logFilePath.fsPath, 3000); 69 | await fs.writeFileSync(logFilePath.fsPath, ""); 70 | Logger.info(`📜 Logfile found and cleared at ${new Date().toISOString()}`); 71 | } 72 | 73 | public static logSystemInformation(context: vscode.ExtensionContext) { 74 | Logger.info(`Operating system: ${os.type()} ${os.release()} ${os.arch()}`); 75 | Logger.info(`Process versions: ${JSON.stringify(process.versions)}`); 76 | 77 | const manimNotebookVersion = context.extension.packageJSON.version; 78 | Logger.info(`Manim Notebook version: ${manimNotebookVersion}`); 79 | 80 | Logger.info("--------------------------"); 81 | } 82 | 83 | /** 84 | * Returns formatted caller information in the form of 85 | * "[filename] [methodname]". 86 | * 87 | * It works by creating a stack trace and extracting the file name from the 88 | * third line of the stack trace, e.g. 89 | * 90 | * Error: 91 | * at Logger.getCurrentFileName (manim-notebook/out/logger.js:32:19) 92 | * at Logger.info (manim-notebook/out/logger.js:46:39) 93 | * at activate (manim-notebook/out/extension.js:37:21) 94 | * ... 95 | * 96 | * where "extension.js:37:21" is the file that called the logger method 97 | * and "activate" is the respective method. 98 | * 99 | * Another example where the Logger is called in a Promise might be: 100 | * 101 | * Error: 102 | * at Function.getFormattedCallerInformation 103 | * (manim-notebook/src/logger.ts:46:23) 104 | * at Function.info (manim-notebook/src/logger.ts:18:31) 105 | * at manim-notebook/src/extension.ts:199:12 106 | * 107 | * where "extension.ts:199:12" is the file that called the logger method 108 | * and the method is unknown. 109 | */ 110 | private static getFormattedCallerInformation(): string { 111 | const error = new Error(); 112 | const stack = error.stack; 113 | 114 | const unknownString = "[unknown] [unknown]"; 115 | 116 | if (!stack) { 117 | return unknownString; 118 | } 119 | 120 | const stackLines = stack.split("\n"); 121 | if (stackLines.length < 4) { 122 | return unknownString; 123 | } 124 | 125 | const callerLine = stackLines[3]; 126 | if (!callerLine) { 127 | return unknownString; 128 | } 129 | 130 | const fileMatch = callerLine.match(/(?:[^\(\s])*?:\d+:\d+/); 131 | let fileName = "unknown"; 132 | if (fileMatch && fileMatch[0]) { 133 | fileName = path.basename(fileMatch[0]); 134 | } 135 | 136 | const methodMatch = callerLine.match(/at ([\w\.]+) \(/); 137 | let methodName = "unknown"; 138 | if (methodMatch && methodMatch[1]) { 139 | methodName = methodMatch[1]; 140 | } 141 | 142 | return `[${fileName}] [${methodName}]`; 143 | } 144 | } 145 | 146 | /** 147 | * Class that wraps some VSCode window methods to log the messages before 148 | * displaying them to the user as a notification. 149 | */ 150 | export class Window { 151 | public static async showInformationMessage(message: string, ...items: string[]) { 152 | Logger.info(`💡 ${message}`); 153 | return await window.showInformationMessage(message, ...items); 154 | } 155 | 156 | public static async showWarningMessage(message: string, ...items: string[]) { 157 | Logger.warn(`💡 ${message}`); 158 | return await window.showWarningMessage(message, ...items); 159 | } 160 | 161 | public static async showErrorMessage(message: string, ...items: string[]) { 162 | Logger.error(`💡 ${message}`); 163 | return await window.showErrorMessage(message, ...items); 164 | } 165 | } 166 | 167 | /** 168 | * Class to manage the recording of a log file. Users can start and stop the 169 | * recording of a log file. The log file is then opened in the file explorer 170 | * afterwards such that they can just drag-and-drop it into a new GitHub issue. 171 | */ 172 | export class LogRecorder { 173 | private static recorderStatusBar: vscode.StatusBarItem; 174 | 175 | /** 176 | * Starts recording a log file. Initializes a new status bar item that 177 | * allows the user to stop the recording. 178 | * 179 | * @param context The extension context. 180 | */ 181 | public static async recordLogFile(context: vscode.ExtensionContext) { 182 | if (Logger.isRecording) { 183 | window.showInformationMessage("A log file is already being recorded."); 184 | return; 185 | } 186 | Logger.isRecording = true; 187 | 188 | let isClearSuccessful = false; 189 | 190 | await window.withProgress({ 191 | location: vscode.ProgressLocation.Notification, 192 | title: "Setting up Manim Notebook Log recording...", 193 | cancellable: false, 194 | }, async (_progressIndicator, _token) => { 195 | try { 196 | await Logger.clear(this.getLogFilePath(context)); 197 | isClearSuccessful = true; 198 | } catch (error: any) { 199 | window.showErrorMessage( 200 | "Please reload your VSCode window to set up the log file: " 201 | + "Command palette -> \"Developer: Reload Window\". " 202 | + `Then try logging again. Current error: ${error?.message}`); 203 | } 204 | }); 205 | 206 | if (!isClearSuccessful) { 207 | Logger.isRecording = false; 208 | return; 209 | } 210 | 211 | this.recorderStatusBar = window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); 212 | this.recorderStatusBar.text = "$(stop-circle) Click to finish recording log file"; 213 | this.recorderStatusBar.command = "manim-notebook.finishRecordingLogFile"; 214 | this.recorderStatusBar.backgroundColor = new vscode.ThemeColor( 215 | "statusBarItem.errorBackground"); 216 | 217 | // Right now, there is no way to set the log level programmatically. 218 | // We can just show the pop-up to do so to users. 219 | // see https://github.com/microsoft/vscode/issues/223536 220 | await vscode.commands.executeCommand("workbench.action.setLogLevel"); 221 | 222 | Logger.info("📜 Logfile recording started"); 223 | Logger.logSystemInformation(context); 224 | this.recorderStatusBar.show(); 225 | } 226 | 227 | /** 228 | * Finishes the active recording of a log file. Called when the user 229 | * clicks on the status bar item initialized in `recordLogFile()`. 230 | * 231 | * @param context The extension context. 232 | */ 233 | public static async finishRecordingLogFile(context: vscode.ExtensionContext) { 234 | Logger.isRecording = false; 235 | this.recorderStatusBar.dispose(); 236 | 237 | await this.openLogFile(this.getLogFilePath(context)); 238 | } 239 | 240 | /** 241 | * Returns the URI of the log file that VSCode initializes for us. 242 | * 243 | * @param context The extension context. 244 | */ 245 | private static getLogFilePath(context: vscode.ExtensionContext): vscode.Uri { 246 | return vscode.Uri.joinPath(context.logUri, `${LOGGER_NAME}.log`); 247 | } 248 | 249 | /** 250 | * Tries to open the log file in an editor and reveal it in the 251 | * OS file explorer. 252 | * 253 | * @param logFilePath The URI of the log file. 254 | */ 255 | private static async openLogFile(logFilePath: vscode.Uri) { 256 | await window.withProgress({ 257 | location: vscode.ProgressLocation.Notification, 258 | title: "Opening Manim Notebook log file...", 259 | cancellable: false, 260 | }, async (_progressIndicator, _token) => { 261 | await new Promise(async (resolve) => { 262 | try { 263 | const doc = await vscode.workspace.openTextDocument(logFilePath); 264 | await window.showTextDocument(doc); 265 | await revealFileInOS(logFilePath); 266 | } catch (error: any) { 267 | window.showErrorMessage("Could not open Manim Notebook log file:" 268 | + ` ${error?.message}`); 269 | } finally { 270 | resolve(); 271 | } 272 | }); 273 | }); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/manimCell.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | CancellationToken, 4 | CodeLens, FoldingContext, FoldingRange, 5 | TextDocument, 6 | window, 7 | } from "vscode"; 8 | import { ManimCellRanges } from "./pythonParsing"; 9 | 10 | export class ManimCell implements vscode.CodeLensProvider, vscode.FoldingRangeProvider { 11 | private cellStartCommentDecoration: vscode.TextEditorDecorationType; 12 | private cellTopDecoration: vscode.TextEditorDecorationType; 13 | private cellBottomDecoration: vscode.TextEditorDecorationType; 14 | private cellTopDecorationUnfocused: vscode.TextEditorDecorationType; 15 | private cellBottomDecorationUnfocused: vscode.TextEditorDecorationType; 16 | 17 | constructor() { 18 | this.cellStartCommentDecoration = window.createTextEditorDecorationType({ 19 | isWholeLine: true, 20 | fontWeight: "bold", 21 | color: new vscode.ThemeColor("manimNotebookColors.baseColor"), 22 | }); 23 | this.cellTopDecoration = ManimCell.getBorder(true, true); 24 | this.cellBottomDecoration = ManimCell.getBorder(true, false); 25 | this.cellTopDecorationUnfocused = ManimCell.getBorder(false, true); 26 | this.cellBottomDecorationUnfocused = ManimCell.getBorder(false, false); 27 | } 28 | 29 | private static getBorder(isFocused = true, isTop = true): vscode.TextEditorDecorationType { 30 | const borderColor = isFocused 31 | ? "manimNotebookColors.baseColor" 32 | : "manimNotebookColors.unfocused"; 33 | const borderWidth = isTop ? "2px 0px 0px 0px" : "0px 0px 1.6px 0px"; 34 | return window.createTextEditorDecorationType({ 35 | borderColor: new vscode.ThemeColor(borderColor), 36 | borderWidth: borderWidth, 37 | borderStyle: "solid", 38 | isWholeLine: true, 39 | }); 40 | } 41 | 42 | public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] { 43 | if (!window.activeTextEditor) { 44 | return []; 45 | } 46 | 47 | const codeLenses: vscode.CodeLens[] = []; 48 | 49 | const ranges = ManimCellRanges.calculateRanges(document); 50 | for (let range of ranges) { 51 | range = new vscode.Range(range.start, range.end); 52 | const codeLens = new vscode.CodeLens(range); 53 | const codeLensReload = new vscode.CodeLens(range); 54 | 55 | const document = window.activeTextEditor.document; 56 | const cellCode = document.getText(range); 57 | 58 | codeLens.command = { 59 | title: "Preview Manim Cell", 60 | command: "manim-notebook.previewManimCell", 61 | tooltip: "Preview this Manim Cell", 62 | arguments: [cellCode, range.start.line], 63 | }; 64 | 65 | codeLensReload.command = { 66 | title: "Reload & Preview", 67 | command: "manim-notebook.reloadAndPreviewManimCell", 68 | tooltip: "Reload & Preview this Manim Cell", 69 | arguments: [cellCode, range.start.line], 70 | }; 71 | 72 | codeLenses.push(codeLens); 73 | codeLenses.push(codeLensReload); 74 | } 75 | 76 | return codeLenses; 77 | } 78 | 79 | public provideFoldingRanges( 80 | document: TextDocument, 81 | _context: FoldingContext, 82 | _token: CancellationToken): FoldingRange[] { 83 | const ranges = ManimCellRanges.calculateRanges(document); 84 | return ranges.map(range => new vscode.FoldingRange(range.start.line, range.end.line)); 85 | } 86 | 87 | public applyCellDecorations(editor: vscode.TextEditor) { 88 | const document = editor.document; 89 | const ranges = ManimCellRanges.calculateRanges(document); 90 | const topRangesFocused: vscode.Range[] = []; 91 | const bottomRangesFocused: vscode.Range[] = []; 92 | const topRangesUnfocused: vscode.Range[] = []; 93 | const bottomRangesUnfocused: vscode.Range[] = []; 94 | 95 | const cursorPosition = editor.selection.active; 96 | 97 | ranges.forEach((range) => { 98 | const isCursorInRange = range.contains(cursorPosition); 99 | const topRange = new vscode.Range(range.start.line, 0, range.start.line, 0); 100 | const bottomRange = new vscode.Range(range.end.line, 0, range.end.line, 0); 101 | 102 | if (isCursorInRange) { 103 | topRangesFocused.push(topRange); 104 | bottomRangesFocused.push(bottomRange); 105 | } else { 106 | topRangesUnfocused.push(topRange); 107 | bottomRangesUnfocused.push(bottomRange); 108 | } 109 | }); 110 | 111 | const config = vscode.workspace.getConfiguration("manim-notebook"); 112 | 113 | // Start comment in bold 114 | if (config.get("typesetStartCommentInBold")) { 115 | editor.setDecorations(this.cellStartCommentDecoration, 116 | topRangesFocused.concat(topRangesUnfocused)); 117 | } else { 118 | editor.setDecorations(this.cellStartCommentDecoration, []); 119 | } 120 | 121 | // Cell borders 122 | if (config.get("showCellBorders")) { 123 | editor.setDecorations(this.cellTopDecoration, topRangesFocused); 124 | editor.setDecorations(this.cellBottomDecoration, bottomRangesFocused); 125 | editor.setDecorations(this.cellTopDecorationUnfocused, topRangesUnfocused); 126 | editor.setDecorations(this.cellBottomDecorationUnfocused, bottomRangesUnfocused); 127 | } else { 128 | editor.setDecorations(this.cellTopDecoration, []); 129 | editor.setDecorations(this.cellBottomDecoration, []); 130 | editor.setDecorations(this.cellTopDecorationUnfocused, []); 131 | editor.setDecorations(this.cellBottomDecorationUnfocused, []); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/manimVersion.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | import * as vscode from "vscode"; 4 | import { Logger, Window } from "./logger"; 5 | 6 | /** 7 | * Manim version that the user has installed without the 'v' prefix, 8 | * e.g. '1.2.3'. 9 | */ 10 | let MANIM_VERSION: string | undefined; 11 | 12 | /** 13 | * The last Python binary path that was used to determine the Manim version. 14 | * 15 | * This is used to retry the version determination if it failed the first time. 16 | */ 17 | let lastPythonBinary: string | undefined; 18 | 19 | /** 20 | * The URL to the Manim releases page. 21 | */ 22 | const MANIM_RELEASES_URL = "https://github.com/3b1b/manim/releases"; 23 | 24 | /** 25 | * The URL to the API endpoint showing information about the latest 26 | * Manim release. 27 | */ 28 | const MANIM_LATEST_RELEASE_API_URL = "https://api.github.com/repos/3b1b/manim/releases/latest"; 29 | 30 | /** 31 | * Checks if the given version is at least the required version. 32 | * 33 | * @param versionRequired The required version, e.g. '1.2.3'. 34 | * @param version The version to compare against, e.g. '1.3.4'. 35 | */ 36 | function isAtLeastVersion(versionRequired: string, version: string): boolean { 37 | const versionRequiredParts = versionRequired.split("."); 38 | const versionParts = version.split("."); 39 | 40 | for (let i = 0; i < versionRequiredParts.length; i++) { 41 | if (versionParts[i] === undefined) { 42 | return false; 43 | } 44 | 45 | const versionPart = parseInt(versionParts[i], 10); 46 | const versionRequiredPart = parseInt(versionRequiredParts[i], 10); 47 | 48 | if (versionPart > versionRequiredPart) { 49 | return true; 50 | } 51 | if (versionPart < versionRequiredPart) { 52 | return false; 53 | } 54 | } 55 | 56 | return true; 57 | } 58 | 59 | /** 60 | * Returns true if the users's Manim version is at least the required version. 61 | * Does not show any warning message should the version be too low. 62 | * 63 | * @param versionRequired The minimal Manim version required, e.g. '1.2.3'. 64 | * @returns True if the user has at least the required Manim version installed. 65 | */ 66 | export function hasUserMinimalManimVersion(versionRequired: string): boolean { 67 | if (!MANIM_VERSION) { 68 | return false; 69 | } 70 | return isAtLeastVersion(versionRequired, MANIM_VERSION); 71 | } 72 | 73 | /** 74 | * Returns true if the users's Manim version is at least the required version. 75 | * Shows a generic warning message should the version be too low. 76 | * 77 | * @param versionRequired The minimal Manim version required, e.g. '1.2.3'. 78 | * @returns True if the user has at least the required Manim version installed. 79 | */ 80 | export async function hasUserMinimalManimVersionAndWarn(versionRequired: string): Promise { 81 | if (hasUserMinimalManimVersion(versionRequired)) { 82 | return true; 83 | } 84 | 85 | const sorryBaseMessage = "Sorry, this feature requires Manim version" 86 | + ` v${versionRequired} or higher.`; 87 | 88 | if (MANIM_VERSION) { 89 | Window.showWarningMessage( 90 | `${sorryBaseMessage} Your current version is v${MANIM_VERSION}.`); 91 | return false; 92 | } 93 | 94 | const tryAgainOption = "Try again to determine version"; 95 | const message = `${sorryBaseMessage} Your current version could not be determined yet.`; 96 | const selected = await Window.showWarningMessage(message, tryAgainOption); 97 | if (selected === tryAgainOption) { 98 | await determineManimVersion(lastPythonBinary); 99 | return hasUserMinimalManimVersion(versionRequired); 100 | } 101 | 102 | return false; 103 | } 104 | 105 | /** 106 | * Returns the tag name of the latest Manim release if the GitHub API is 107 | * reachable. This tag name won't include the 'v' prefix, e.g. '1.2.3'. 108 | */ 109 | async function fetchLatestManimVersion(): Promise { 110 | try { 111 | const controller = new AbortController(); 112 | const timeoutId = setTimeout(() => controller.abort(), 5000); 113 | 114 | const response = await fetch(MANIM_LATEST_RELEASE_API_URL, { 115 | headers: { 116 | Accept: "application/vnd.github+json", 117 | }, 118 | signal: controller.signal, 119 | }); 120 | 121 | clearTimeout(timeoutId); 122 | 123 | if (!response.ok) { 124 | Logger.error(`GitHub API error: ${response.statusText}`); 125 | return undefined; 126 | } 127 | 128 | const data: any = await response.json(); 129 | const tag = data.tag_name; 130 | return tag.startsWith("v") ? tag.substring(1) : tag; 131 | } catch (error) { 132 | Logger.error(`Error fetching latest Manim version: ${error}`); 133 | return undefined; 134 | } 135 | } 136 | 137 | /** 138 | * Determines the ManimGL version. 139 | * 140 | * Note that this *tries* to determine the version, but it could fail, in which 141 | * case the user will be informed about this and the MANIM_VERSION is set to 142 | * undefined. 143 | * 144 | * @param pythonBinary The path to the Python binary. 145 | */ 146 | export async function determineManimVersion(pythonBinary: string | undefined) { 147 | if (!pythonBinary) { 148 | Logger.error("Python binary path is undefined"); 149 | return; 150 | } 151 | lastPythonBinary = pythonBinary; 152 | MANIM_VERSION = undefined; 153 | let couldDetermineManimVersion = false; 154 | 155 | const timeoutPromise = new Promise((resolve, _reject) => { 156 | setTimeout(() => { 157 | Logger.debug("Manim Version Determination: timed out"); 158 | resolve(false); 159 | }, 3000); 160 | }); 161 | 162 | const versionCommand = `"${pythonBinary}" -c \"from importlib.metadata import version; ` 163 | + " print(version('manimgl'))\""; 164 | 165 | try { 166 | couldDetermineManimVersion = await Promise.race( 167 | [execVersionCommandAndCheckForSuccess(versionCommand), timeoutPromise]); 168 | } catch (err) { 169 | Logger.error(`Abnormal termination of ManimGL Version Check: ${err}`); 170 | } 171 | 172 | if (couldDetermineManimVersion) { 173 | Logger.info(`👋 ManimGL version found: ${MANIM_VERSION}`); 174 | await showPositiveUserVersionFeedback(); 175 | } else { 176 | Logger.info("👋 ManimGL version could not be determined"); 177 | await showNegativeUserVersionFeedback(); 178 | } 179 | } 180 | 181 | async function showPositiveUserVersionFeedback() { 182 | const latestVersion = await fetchLatestManimVersion(); 183 | if (!latestVersion) { 184 | Window.showInformationMessage(`You're using ManimGL version: v${MANIM_VERSION}`); 185 | return; 186 | } 187 | 188 | if (latestVersion === MANIM_VERSION) { 189 | Window.showInformationMessage( 190 | `You're using the latest ManimGL version: v${MANIM_VERSION}`); 191 | return; 192 | } 193 | 194 | const showReleasesOption = "Show Manim releases"; 195 | const selected = await Window.showInformationMessage( 196 | `You're using ManimGL version v${MANIM_VERSION}.` 197 | + ` The latest version is v${latestVersion}.`, showReleasesOption); 198 | if (selected === showReleasesOption) { 199 | vscode.env.openExternal(vscode.Uri.parse(MANIM_RELEASES_URL)); 200 | } 201 | } 202 | 203 | async function showNegativeUserVersionFeedback() { 204 | const tryAgainOption = "Try again"; 205 | const openWalkthroughOption = "Open Walkthrough"; 206 | const warningMessage = "Your ManimGL version could not be determined."; 207 | const selected = await Window.showWarningMessage( 208 | warningMessage, tryAgainOption, openWalkthroughOption); 209 | if (selected === tryAgainOption) { 210 | await determineManimVersion(lastPythonBinary); 211 | } else if (selected === openWalkthroughOption) { 212 | await vscode.commands.executeCommand("manim-notebook.openWalkthrough"); 213 | } 214 | } 215 | 216 | /** 217 | * Executes the given command as child process. We listen to the output and 218 | * look for the ManimGL version string. 219 | * 220 | * @param command The command to execute in the shell (via Node.js `exec`). 221 | * @returns A promise that resolves to true if the version was successfully 222 | * determined, and false otherwise. Might never resolve, so the caller should 223 | * let this promise race with a timeout promise. 224 | */ 225 | async function execVersionCommandAndCheckForSuccess(command: string): Promise { 226 | return new Promise(async (resolve, _reject) => { 227 | exec(command, (error, stdout, stderr) => { 228 | if (error) { 229 | Logger.error(`Manim Version Check. error: ${error.message}`); 230 | resolve(false); 231 | return; 232 | } 233 | if (stderr) { 234 | Logger.error(`Manim Version Check. stderr: ${stderr}`); 235 | resolve(false); 236 | return; 237 | } 238 | 239 | Logger.trace(`Manim Version Check. stdout: ${stdout}`); 240 | const versionMatch = stdout.match(/^\s*([0-9]+\.[0-9]+\.[0-9]+)/m); 241 | if (!versionMatch) { 242 | Logger.debug("Manim Version Check. Found stdout, but no version match"); 243 | resolve(false); 244 | return; 245 | } 246 | MANIM_VERSION = versionMatch[1]; 247 | resolve(true); 248 | }); 249 | }); 250 | } 251 | -------------------------------------------------------------------------------- /src/patches/applyPatches.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import * as fs from "fs"; 3 | import path from "path"; 4 | 5 | import { Logger, Window } from "../logger"; 6 | 7 | import * as vscode from "vscode"; 8 | import { ExtensionContext } from "vscode"; 9 | 10 | const PATCH_INFO_URL = "https://github.com/Manim-Notebook/manim-notebook/wiki/%F0%9F%A4%A2-Troubleshooting#windows-paste-patch"; 11 | 12 | /** 13 | * Applies the Windows paste patch to the user's Python environment such that 14 | * cell execution in IPython works correctly on Windows. 15 | * 16 | * More information in the troubleshooting wiki: 17 | * https://github.com/Manim-Notebook/manim-notebook/wiki/%F0%9F%A4%A2-Troubleshooting#windows-paste-patch 18 | * 19 | * @param context The extension context. 20 | * @param pythonBinary The path to the Python binary. 21 | */ 22 | export async function applyWindowsPastePatch( 23 | context: ExtensionContext, pythonBinary: string, 24 | ) { 25 | const pathToPatch = path.join(context.extensionPath, 26 | "src", "patches", "install_windows_paste_patch.py"); 27 | const patch = fs.readFileSync(pathToPatch, "utf-8"); 28 | // we encode/decode as base64 to avoid nasty errors when escaping manually 29 | const encodedPatch = Buffer.from(patch, "utf-8").toString("base64"); 30 | const patchCommand = `${pythonBinary} -c "import base64;` 31 | + ` exec(base64.b64decode('${encodedPatch}').decode('utf-8'))"`; 32 | 33 | const timeoutPromise = new Promise((resolve, _reject) => { 34 | setTimeout(() => { 35 | Logger.debug("Windows Paste Patch Terminal: timed out"); 36 | resolve(false); 37 | }, 4000); 38 | }); 39 | 40 | try { 41 | const patchApplied = await Promise.race( 42 | [execPatchAndCheckForSuccess(patchCommand), timeoutPromise], 43 | ); 44 | if (patchApplied) { 45 | Logger.info("Windows paste patch successfully applied (in applyPatches.ts)"); 46 | return; 47 | } 48 | 49 | const action = "Learn more"; 50 | const selected = await Window.showErrorMessage( 51 | "Windows paste patch could not be applied. " 52 | + "Manim Notebook will likely not function correctly for you. " 53 | + "Please check the wiki for more information and report the issue.", 54 | action); 55 | if (selected === action) { 56 | vscode.env.openExternal(vscode.Uri.parse(PATCH_INFO_URL)); 57 | } 58 | } catch (err) { 59 | Logger.error(`Abnormal termination while applying windows paste patch: ${err}`); 60 | } 61 | } 62 | 63 | /** 64 | * Executes the given command as child process. We listen to the output and 65 | * look for the message that indicates that the Windows paste patch was 66 | * successfully applied. 67 | * 68 | * @param command The command to execute in the shell (via Node.js `exec`). 69 | * @returns A promise that resolves to true if the patch was successfully 70 | * applied, and false otherwise. Might never resolve, so the caller should 71 | * let this promise race with a timeout promise. 72 | */ 73 | async function execPatchAndCheckForSuccess(command: string): Promise { 74 | return new Promise(async (resolve, _reject) => { 75 | exec(command, (error, stdout, stderr) => { 76 | if (error) { 77 | Logger.error(`Windows Paste Patch Exec. error: ${error.message}`); 78 | resolve(false); 79 | return; 80 | } 81 | if (stderr) { 82 | Logger.error(`Windows Paste Patch Exec. stderr: ${stderr}`); 83 | resolve(false); 84 | return; 85 | } 86 | 87 | Logger.trace(`Windows Paste Patch Exec. stdout: ${stdout}`); 88 | const isSuccess = stdout.includes("42000043ManimNotebook31415"); 89 | resolve(isSuccess); 90 | }); 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /src/patches/install_windows_paste_patch.py: -------------------------------------------------------------------------------- 1 | import sysconfig 2 | import os 3 | 4 | PATCH_FILE_NAME = "__manim_notebook_windows_paste_patch" 5 | PATCH_PTH = f"import {PATCH_FILE_NAME}" 6 | 7 | PATCH = ''' 8 | import sys 9 | 10 | 11 | def monkey_patch_win32_recognize_paste(): 12 | """ 13 | Disables the "guessed bracketed mode" on Windows to avoid treating key 14 | strokes as paste events whenever the machine is slow to process input. 15 | 16 | Patch provided in: 17 | https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1894#issuecomment-2601043935 18 | 19 | If you want to disable this patch, simply remove this file and the 20 | corresponding __manim_notebook_windows_patch.pth file from your 21 | site-packages directory. 22 | """ 23 | if sys.platform != "win32": 24 | return 25 | 26 | try: 27 | import prompt_toolkit.input.win32 28 | 29 | win32 = sys.modules["prompt_toolkit.input.win32"] 30 | original_class = win32.Win32Input 31 | original_init = original_class.__init__ 32 | 33 | def patched_init(self, stdin=None, *args, **kwargs): 34 | original_init(self, stdin, *args, **kwargs) 35 | if hasattr(self, "console_input_reader"): 36 | self.console_input_reader.recognize_paste = False 37 | 38 | original_class.__init__ = patched_init 39 | 40 | except ImportError as exc: 41 | raise ImportError( 42 | "Monkey-patching module 'prompt_toolkit.input.win32' failed as" 43 | + " we could not import it. Please report this issue to the" 44 | + " Manim Notebook developers on GitHub:" 45 | + " https://github.com/Manim-Notebook/manim-notebook" 46 | ) from exc 47 | 48 | except Exception as exc: 49 | raise Exception( 50 | "Monkey-patching module 'prompt_toolkit.input.win32' failed" 51 | + " due to an unexpected error. Please report this issue to the" 52 | + " Manim Notebook developers on GitHub:" 53 | + " https://github.com/Manim-Notebook/manim-notebook" 54 | + f" Original error: {exc}" 55 | ) from exc 56 | 57 | 58 | monkey_patch_win32_recognize_paste() 59 | 60 | ''' 61 | 62 | 63 | def install_patch(): 64 | """ 65 | Installs the Windows paste patch by copying the patch file to the 66 | site-packages directory and creating a .pth file that imports the patch. 67 | 68 | This patch will be applied whenever the Python interpreter is started. 69 | """ 70 | # Find path to the site-packages directory 71 | # https://stackoverflow.com/a/52638888/ 72 | site_packages_path = sysconfig.get_path("purelib") 73 | 74 | # Copy the patch file to the site-packages directory 75 | patch_file_path = os.path.join(site_packages_path, f"{PATCH_FILE_NAME}.py") 76 | with open(patch_file_path, "w", encoding="utf-8") as patch_file: 77 | patch_file.write(PATCH) 78 | 79 | # https://github.com/pypa/virtualenv/issues/1703#issuecomment-596618912 80 | # Create a .pth file in the site-packages directory that imports the patch 81 | # (necessary since sitecustomize.py does not work in virtual environments) 82 | pth_file_path = os.path.join(site_packages_path, f"{PATCH_FILE_NAME}.pth") 83 | with open(pth_file_path, "w", encoding="utf-8") as pth_file: 84 | pth_file.write(PATCH_PTH) 85 | 86 | 87 | install_patch() 88 | # Construct a string that we check against to verify that the patch was applied 89 | # successfully. The actual string should not be present in the source code since 90 | # it could be included in the output of the command itself when this whole file 91 | # is sent to the Python interpreter. 92 | SUCCESS_SIGNATURE = "42" + "0" * 4 + "43" + "Manim" + "Notebook" + "31415" 93 | print("Manim Notebook: Windows paste patch applied") 94 | print(SUCCESS_SIGNATURE) 95 | print( 96 | "For more information, see https://github.com/Manim-Notebook/manim-notebook/wiki/%F0%9F%A4%A2-Troubleshooting#windows-paste-patch" 97 | ) 98 | -------------------------------------------------------------------------------- /src/previewCode.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import * as vscode from "vscode"; 3 | import { window } from "vscode"; 4 | import { Logger, Window } from "./logger"; 5 | import { ManimShell } from "./manimShell"; 6 | import { hasUserMinimalManimVersionAndWarn } from "./manimVersion"; 7 | import { ManimCellRanges } from "./pythonParsing"; 8 | 9 | // \x0C: is Ctrl + L, which clears the terminal screen 10 | const PREVIEW_COMMAND = "\x0Ccheckpoint_paste()"; 11 | 12 | function parsePreviewCellArgs(cellCode?: string, startLine?: number) { 13 | let startLineParsed: number | undefined = startLine; 14 | 15 | // User has executed the command via command pallette 16 | if (cellCode === undefined) { 17 | const editor = window.activeTextEditor; 18 | if (!editor) { 19 | Window.showErrorMessage( 20 | "No opened file found. Place your cursor in a Manim cell."); 21 | return; 22 | } 23 | const document = editor.document; 24 | 25 | // Get the code of the cell where the cursor is placed 26 | const cursorLine = editor.selection.active.line; 27 | const range = ManimCellRanges.getCellRangeAtLine(document, cursorLine); 28 | if (!range) { 29 | Window.showErrorMessage("Place your cursor in a Manim cell."); 30 | return; 31 | } 32 | cellCode = document.getText(range); 33 | startLineParsed = range.start.line; 34 | } 35 | 36 | if (startLineParsed === undefined) { 37 | Window.showErrorMessage( 38 | "Internal error: Line number not found in `parsePreviewCellArgs()`."); 39 | return; 40 | } 41 | 42 | return { cellCodeParsed: cellCode, startLineParsed }; 43 | } 44 | 45 | /** 46 | * Previews all code inside of a Manim cell. 47 | * 48 | * A Manim cell starts with `##`. 49 | * 50 | * This can be invoked by either: 51 | * - clicking the code lens (the button above the cell) 52 | * -> this cell is previewed 53 | * - command pallette 54 | * -> the 1 cell where the cursor is is previewed 55 | * 56 | * If Manim isn't running, it will be automatically started 57 | * (at the start of the cell which will be previewed: on its starting ## line), 58 | * and then this cell is previewed. 59 | */ 60 | export async function previewManimCell(cellCode?: string, startLine?: number) { 61 | const res = parsePreviewCellArgs(cellCode, startLine); 62 | if (!res) { 63 | return; 64 | } 65 | const { cellCodeParsed, startLineParsed } = res; 66 | 67 | await previewCode(cellCodeParsed, startLineParsed); 68 | } 69 | 70 | export async function reloadAndPreviewManimCell(cellCode?: string, startLine?: number) { 71 | if (!await hasUserMinimalManimVersionAndWarn("1.7.2")) { 72 | return; 73 | } 74 | 75 | const res = parsePreviewCellArgs(cellCode, startLine); 76 | if (!res) { 77 | return; 78 | } 79 | const { cellCodeParsed, startLineParsed } = res; 80 | 81 | if (ManimShell.instance.hasActiveShell()) { 82 | const reloadCmd = `reload(${startLineParsed + 1})`; 83 | await ManimShell.instance.nextTimeWaitForRestartedIPythonInstance(); 84 | await ManimShell.instance.executeIPythonCommandExpectSession(reloadCmd, true); 85 | } 86 | await previewManimCell(cellCodeParsed, startLineParsed); 87 | } 88 | 89 | /** 90 | * Interactively previews the given Manim code by means of the 91 | * `checkpoint_paste()` method from Manim. 92 | * 93 | * This workflow is described in [1] and was adapted from Sublime to VSCode by 94 | * means of this extension. The main features are discussed in [2]. A demo 95 | * is shown in the video "How I animate 3Blue1Brown" by Grant Sanderson [3], 96 | * with the workflow being showcased between 3:32 and 6:48. 97 | * 98 | * [1] https://github.com/3b1b/videos#workflow 99 | * [2] https://github.com/ManimCommunity/manim/discussions/3954#discussioncomment-10933720 100 | * [3] https://youtu.be/rbu7Zu5X1zI 101 | * 102 | * @param code The code to preview (e.g. from a Manim cell or from a 103 | * custom selection). 104 | * @param startLine The line number in the active editor where the Manim session 105 | * should start in case a new terminal is spawned. Also see `startScene(). 106 | */ 107 | export async function previewCode(code: string, startLine: number): Promise { 108 | let progress: PreviewProgress | undefined; 109 | 110 | try { 111 | const clipboardBuffer = await vscode.env.clipboard.readText(); 112 | await ManimShell.instance.executeIPythonCommand( 113 | PREVIEW_COMMAND, startLine, true, { 114 | 115 | beforeCommandIssued: async () => { 116 | await vscode.env.clipboard.writeText(code); 117 | }, 118 | 119 | onCommandIssued: (shellStillExists) => { 120 | Logger.debug(`📊 Command issued: ${PREVIEW_COMMAND}. Will restore clipboard`); 121 | restoreClipboard(clipboardBuffer); 122 | if (shellStillExists) { 123 | Logger.debug("📊 Initializing preview progress"); 124 | progress = new PreviewProgress(); 125 | } else { 126 | Logger.debug("📊 Shell was closed in the meantime, not showing progress"); 127 | } 128 | }, 129 | 130 | onData: (data) => { 131 | progress?.reportOnData(data); 132 | }, 133 | 134 | onReset: () => { 135 | progress?.finish(); 136 | }, 137 | 138 | }); 139 | } finally { 140 | progress?.finish(); 141 | } 142 | } 143 | 144 | /** 145 | * Restores the clipboard content after a user-defined timeout. 146 | * 147 | * @param clipboardBuffer The content to restore. 148 | */ 149 | function restoreClipboard(clipboardBuffer: string) { 150 | const timeout = vscode.workspace.getConfiguration("manim-notebook").clipboardTimeout; 151 | setTimeout(async () => { 152 | await vscode.env.clipboard.writeText(clipboardBuffer); 153 | }, timeout); 154 | } 155 | 156 | /** 157 | * Class to handle the progress notification of the preview code command. 158 | */ 159 | class PreviewProgress { 160 | private eventEmitter = new EventEmitter(); 161 | private FINISH_EVENT = "finished"; 162 | private REPORT_EVENT = "report"; 163 | 164 | /** 165 | * Current progress of the preview command. 166 | */ 167 | private progress: number = 0; 168 | 169 | /** 170 | * Name of the animation being previewed. 171 | */ 172 | private animationName: string | undefined; 173 | 174 | constructor() { 175 | window.withProgress({ 176 | location: vscode.ProgressLocation.Notification, 177 | title: "Previewing Manim", 178 | cancellable: false, 179 | }, async (progressIndicator, _token) => { 180 | await new Promise((resolve) => { 181 | this.eventEmitter.on(this.FINISH_EVENT, resolve); 182 | this.eventEmitter.on(this.REPORT_EVENT, 183 | (data: { increment: number; message: string }) => { 184 | this.progress += data.increment; 185 | progressIndicator.report( 186 | { 187 | increment: data.increment, 188 | message: data.message, 189 | }); 190 | }); 191 | }); 192 | }); 193 | } 194 | 195 | /** 196 | * Updates the progress based on the given Manim preview output. 197 | * E.g. `2 ShowCreationNumberPlane, etc.: 7%| 2 /30 16.03it/s` 198 | * 199 | * @param data The Manim preview output to parse. 200 | */ 201 | public reportOnData(data: string) { 202 | const newProgress = this.extractProgressFromString(data); 203 | if (newProgress === -1) { 204 | return; 205 | } 206 | let progressIncrement = newProgress - this.progress; 207 | 208 | const split = data.split(" "); 209 | if (split.length < 2) { 210 | return; 211 | } 212 | let newAnimName = data.split(" ")[1]; 213 | // remove last char which is a ":" 214 | newAnimName = newAnimName.substring(0, newAnimName.length - 1); 215 | if (newAnimName !== this.animationName) { 216 | progressIncrement = -this.progress; // reset progress to 0 217 | this.animationName = newAnimName; 218 | } 219 | 220 | Logger.debug(`📊 Progress: ${this.progress} -> ${newProgress} (${progressIncrement})`); 221 | 222 | this.eventEmitter.emit(this.REPORT_EVENT, { 223 | increment: progressIncrement, 224 | message: newAnimName, 225 | }); 226 | } 227 | 228 | /** 229 | * Finishes the progress notification, i.e. closes the progress bar. 230 | */ 231 | public finish() { 232 | Logger.debug("📊 Finishing progress notification"); 233 | this.eventEmitter.emit(this.FINISH_EVENT); 234 | } 235 | 236 | /** 237 | * Extracts the progress information from the given Manim preview output. 238 | * 239 | * @param data The Manim preview output to parse. 240 | * @returns The progress percentage as in the interval [0, 100], 241 | * or -1 if no progress information was found. 242 | */ 243 | private extractProgressFromString(data: string): number { 244 | if (!data.includes("%")) { 245 | return -1; 246 | } 247 | const progressString = data.match(/\b\d{1,2}(?=\s?%)/)?.[0]; 248 | if (!progressString) { 249 | return -1; 250 | } 251 | 252 | return parseInt(progressString); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/pythonParsing.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import { Range, TextDocument } from "vscode"; 3 | 4 | /** 5 | * Cache is a simple key-value store that keeps a maximum number of entries. 6 | * The key is a VSCode TextDocument, and the value is of the generic type T. 7 | * 8 | * The cache is used to store the results of expensive calculations, e.g. the 9 | * Manim cell ranges in a document. 10 | */ 11 | class Cache { 12 | private cache: Map = new Map(); 13 | private static readonly MAX_CACHE_SIZE = 100; 14 | 15 | private hash(document: TextDocument): string { 16 | const text = document.getText(); 17 | const hash = crypto.createHash("md5").update(text); 18 | return hash.digest("hex"); 19 | } 20 | 21 | public get(document: TextDocument): T | undefined { 22 | const key = this.hash(document); 23 | return this.cache.get(key); 24 | } 25 | 26 | public add(document: TextDocument, value: T): void { 27 | if (this.cache.size >= Cache.MAX_CACHE_SIZE) { 28 | const keys = this.cache.keys(); 29 | const firstKey = keys.next().value; 30 | if (firstKey) { 31 | this.cache.delete(firstKey); 32 | } 33 | } 34 | this.cache.set(this.hash(document), value); 35 | } 36 | } 37 | 38 | const cellRangesCache = new Cache(); 39 | const manimClassesCache = new Cache(); 40 | 41 | /** 42 | * ManimCellRanges calculates the ranges of Manim cells in a given document. 43 | * It is used to provide folding ranges, code lenses, and decorations for Manim 44 | * Cells in the editor. 45 | */ 46 | export class ManimCellRanges { 47 | /** 48 | * Regular expression to match the start of a Manim cell. 49 | * 50 | * The marker is a comment line starting with "##". That is, "### # #" is also 51 | * considered a valid marker. 52 | * 53 | * Since the comment might be indented, we allow for any number of 54 | * leading whitespaces. 55 | * 56 | * Manim cells themselves might contain further comments, but no nested 57 | * Manim cells, i.e. no further comment starting with "##". 58 | */ 59 | private static readonly MARKER = /^(\s*##)/; 60 | 61 | /** 62 | * Calculates the ranges of Manim cells in the given document. 63 | * 64 | * A new Manim cell starts at a custom MARKER. The cell ends either: 65 | * - when the next line starts with the MARKER 66 | * - when the indentation level decreases 67 | * - at the end of the document 68 | * 69 | * Manim Cells are only recognized inside the construct() method of a 70 | * Manim class (see `ManimClass`). 71 | */ 72 | public static calculateRanges(document: TextDocument): Range[] { 73 | const cachedRanges = cellRangesCache.get(document); 74 | if (cachedRanges) { 75 | return cachedRanges; 76 | } 77 | 78 | const ranges: Range[] = []; 79 | const manimClasses = ManimClass.findAllIn(document); 80 | 81 | manimClasses.forEach((manimClass) => { 82 | const construct = manimClass.constructMethod; 83 | const startCell = construct.bodyRange.start; 84 | const endCell = construct.bodyRange.end; 85 | let start = startCell; 86 | let end = startCell; 87 | 88 | let inManimCell = false; 89 | 90 | for (let i = startCell; i <= endCell; i++) { 91 | const line = document.lineAt(i); 92 | const indentation = line.firstNonWhitespaceCharacterIndex; 93 | 94 | if (indentation === construct.bodyIndent && this.MARKER.test(line.text)) { 95 | if (inManimCell) { 96 | ranges.push(this.constructRange(document, start, end)); 97 | } 98 | inManimCell = true; 99 | start = i; 100 | end = i; 101 | } else { 102 | if (!inManimCell) { 103 | start = i; 104 | } 105 | end = i; 106 | } 107 | } 108 | 109 | // last cell 110 | if (inManimCell) { 111 | ranges.push(this.constructRange(document, start, endCell)); 112 | } 113 | }); 114 | 115 | cellRangesCache.add(document, ranges); 116 | return ranges; 117 | } 118 | 119 | /** 120 | * Returns the cell range of the Manim Cell at the given line number. 121 | * 122 | * Returns null if no cell range contains the line, e.g. if the cursor is 123 | * outside of a Manim cell. 124 | */ 125 | public static getCellRangeAtLine(document: TextDocument, line: number): Range | null { 126 | const ranges = this.calculateRanges(document); 127 | for (const range of ranges) { 128 | if (range.start.line <= line && line <= range.end.line) { 129 | return range; 130 | } 131 | } 132 | return null; 133 | } 134 | 135 | /** 136 | * Constructs a new cell range from the given start and end line numbers. 137 | * Discards all trailing empty lines at the end of the range. 138 | * 139 | * The column is set to 0 for `start` and to the end of the line for `end`. 140 | */ 141 | private static constructRange(document: TextDocument, start: number, end: number): Range { 142 | let endNew = end; 143 | while (endNew > start && document.lineAt(endNew).isEmptyOrWhitespace) { 144 | endNew--; 145 | } 146 | return new Range(start, 0, endNew, document.lineAt(endNew).text.length); 147 | } 148 | } 149 | 150 | /** 151 | * A range of lines in a document. Both start and end are inclusive and 0-based. 152 | */ 153 | interface LineRange { 154 | start: number; 155 | end: number; 156 | } 157 | 158 | /** 159 | * Information for a method, including the range of the method body and the 160 | * indentation level of the body. 161 | * 162 | * This is used to gather infos about the construct() method of a Manim class. 163 | */ 164 | interface MethodInfo { 165 | bodyRange: LineRange; 166 | bodyIndent: number; 167 | } 168 | 169 | /** 170 | * A Manim class is defined as: 171 | * 172 | * - Inherits from any object. This constraint is necessary since in the chain 173 | * of class inheritance, the base class must inherit from "Scene", otherwise 174 | * Manim itself won't recognize the class as a scene. We don't enforce the 175 | * name "Scene" here since subclasses might also inherit from other classes. 176 | * 177 | * - Contains a "def construct(self)" method with exactly this signature. 178 | * 179 | * This class provides static methods to work with Manim classes in a 180 | * Python document. 181 | */ 182 | export class ManimClass { 183 | /** 184 | * Regular expression to match a class that inherits from any object. 185 | * The class name is captured in the first group. 186 | * 187 | * Note that this regex doesn't trigger on MyClassName() since we expect 188 | * some words inside the parentheses, e.g. MyClassName(MyBaseClass). 189 | * 190 | * This regex and the class regex should not trigger both on the same input. 191 | */ 192 | private static INHERITED_CLASS_REGEX = /^\s*class\s+(\w+)\s*\(\w.*\)\s*:/; 193 | 194 | /** 195 | * Regular expression to match a class definition. 196 | * The class name is captured in the first group. 197 | * 198 | * This includes the case MyClassName(), but not MyClassName(AnyClass). 199 | * The class name is captured in the first group. 200 | * 201 | * This regex and the inherited class regex should not trigger both 202 | * on the same input. 203 | */ 204 | private static CLASS_REGEX = /^\s*class\s+(\w+)\s*(\(\s*\))?\s*:/; 205 | 206 | /** 207 | * Regular expression to match the construct() method definition. 208 | */ 209 | private static CONSTRUCT_METHOD_REGEX = /^\s*def\s+construct\s*\(self\)\s*(->\s*None)?\s*:/; 210 | 211 | /** 212 | * The 0-based line number where the Manim Class is defined. 213 | */ 214 | lineNumber: number; 215 | 216 | /** 217 | * The name of the Manim Class. 218 | */ 219 | className: string; 220 | 221 | /** 222 | * The indentation level of the class definition. 223 | */ 224 | classIndent: number; 225 | 226 | /** 227 | * Information about the construct() method of the Manim Class. 228 | */ 229 | constructMethod: MethodInfo; 230 | 231 | constructor(lineNumber: number, className: string, classIndent: number) { 232 | this.lineNumber = lineNumber; 233 | this.className = className; 234 | this.classIndent = classIndent; 235 | this.constructMethod = { bodyRange: { start: -1, end: -1 }, bodyIndent: -1 }; 236 | } 237 | 238 | /** 239 | * Returns all ManimClasses in the given document. 240 | * 241 | * @param document The document to search in. 242 | */ 243 | public static findAllIn(document: TextDocument): ManimClass[] { 244 | const cachedClasses = manimClassesCache.get(document); 245 | if (cachedClasses) { 246 | return cachedClasses; 247 | } 248 | 249 | const lines = document.getText().split("\n"); 250 | const classes: ManimClass[] = []; 251 | let manimClass: ManimClass | null = null; 252 | 253 | for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { 254 | const line = lines[lineNumber]; 255 | 256 | const match = line.match(this.INHERITED_CLASS_REGEX); 257 | if (match) { 258 | manimClass = new ManimClass(lineNumber, match[1], line.search(/\S/)); 259 | classes.push(manimClass); 260 | continue; 261 | } 262 | 263 | if (line.match(this.CLASS_REGEX)) { 264 | // only trigger when not a nested class 265 | if (manimClass && line.search(/\S/) <= manimClass.classIndent) { 266 | manimClass = null; 267 | } 268 | continue; 269 | } 270 | 271 | if (manimClass && line.match(this.CONSTRUCT_METHOD_REGEX)) { 272 | manimClass.constructMethod = this.makeConstructMethodInfo(lines, lineNumber); 273 | manimClass = null; 274 | } 275 | } 276 | 277 | const filtered = classes.filter(c => c.constructMethod.bodyRange.start !== -1); 278 | manimClassesCache.add(document, filtered); 279 | return filtered; 280 | } 281 | 282 | /** 283 | * Calculates the range of the construct() method of the Manim class and 284 | * returns it along with the indentation level of the method body. 285 | * 286 | * The construct method is said to end when the indentation level of one 287 | * of the following lines is lower than the indentation level of the first 288 | * line of the method body. 289 | * 290 | * @param lines The lines of the document. 291 | * @param lineNumber The line number where the construct() method is defined. 292 | */ 293 | private static makeConstructMethodInfo(lines: string[], lineNumber: number): MethodInfo { 294 | const bodyIndent = lines[lineNumber + 1].search(/\S/); 295 | const bodyRange = { start: lineNumber + 1, end: lineNumber + 1 }; 296 | 297 | // Safety check: not even start line of body range accessible 298 | if (bodyRange.start >= lines.length) { 299 | return { bodyRange, bodyIndent }; 300 | } 301 | 302 | for (let i = bodyRange.start; i < lines.length; i++) { 303 | const line = lines[i]; 304 | if (!line.trim()) { 305 | continue; // skip empty lines 306 | } 307 | 308 | const indent = line.search(/\S/); 309 | if (indent < bodyIndent) { 310 | break; // e.g. next class or method found 311 | } 312 | bodyRange.end = i; 313 | } 314 | 315 | return { bodyRange, bodyIndent }; 316 | } 317 | 318 | /** 319 | * Returns the ManimClass at the given cursor position (if any). 320 | * 321 | * @param document The document to search in. 322 | * @param cursorLine The line number of the cursor. 323 | * @returns The ManimClass at the cursor position, or undefined if not found. 324 | */ 325 | public static getManimClassAtCursor(document: TextDocument, cursorLine: number): 326 | ManimClass | undefined { 327 | const manimClasses = this.findAllIn(document); 328 | return manimClasses.reverse().find(({ lineNumber }) => lineNumber <= cursorLine); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/startStopScene.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { window, workspace } from "vscode"; 3 | import { Logger, Window } from "./logger"; 4 | import { ManimShell, NoActiveShellError } from "./manimShell"; 5 | import { hasUserMinimalManimVersion } from "./manimVersion"; 6 | import { ManimClass } from "./pythonParsing"; 7 | 8 | /** 9 | * Runs the `manimgl` command in the terminal, with the current cursor's 10 | * line number: manimgl [-se ] 11 | * 12 | * - Saves the active file. 13 | * - Previews the scene at the cursor's line (end of line) 14 | * - If the cursor is on a class definition line, then `-se ` 15 | * is NOT added, i.e. the whole scene is previewed. 16 | * - (3b1b's version also copies this command to the clipboard with additional 17 | * args `--prerun --finder -w`. We don't do that here.) 18 | * 19 | * @param lineStart The line number where the scene should start. If omitted, 20 | * the scene will start from the current cursor position, which is the default 21 | * behavior when the command is invoked from the command palette. 22 | * This parameter is set when invoked from ManimShell in order to spawn a new 23 | * scene for another command at the given line number. You are probably doing 24 | * something wrong if you invoke this method with lineStart !== undefined 25 | * from somewhere else than the ManimShell. 26 | */ 27 | export async function startScene(lineStart?: number) { 28 | const editor = window.activeTextEditor; 29 | if (!editor) { 30 | Window.showErrorMessage( 31 | "No opened file found. Please place your cursor at a line of code.", 32 | ); 33 | return; 34 | } 35 | 36 | await vscode.commands.executeCommand("workbench.action.files.save"); 37 | 38 | const languageId = editor.document.languageId; 39 | if (languageId !== "python") { 40 | Window.showErrorMessage("You don't have a Python file open."); 41 | return; 42 | } 43 | 44 | const lines = editor.document.getText().split("\n"); 45 | let cursorLine = lineStart || editor.selection.start.line; 46 | const sceneClassLine = ManimClass.getManimClassAtCursor(editor.document, cursorLine); 47 | if (!sceneClassLine) { 48 | Window.showErrorMessage("Place your cursor in Manim code inside a class."); 49 | return; 50 | } 51 | 52 | // While line is empty - make it the previous line 53 | // (because `manimgl -se ` doesn't work on empty lines) 54 | let lineNumber = cursorLine; 55 | while (lines[lineNumber].trim() === "") { 56 | lineNumber--; 57 | } 58 | 59 | // Create the command 60 | const filePath = editor.document.fileName; // absolute path 61 | const cmds = ["manimgl", `"${filePath}"`, sceneClassLine.className]; 62 | let shouldPreviewWholeScene = true; 63 | if (cursorLine !== sceneClassLine.lineNumber) { 64 | // this is actually the more common case 65 | shouldPreviewWholeScene = false; 66 | cmds.push(`-se ${lineNumber + 1}`); 67 | } 68 | 69 | // Autoreload 70 | if (hasUserMinimalManimVersion("1.7.2")) { 71 | const autoreload = await workspace.getConfiguration("manim-notebook").get("autoreload"); 72 | if (autoreload) { 73 | cmds.push("--autoreload"); 74 | } 75 | } 76 | 77 | const command = cmds.join(" "); 78 | 79 | // Run the command 80 | const isRequestedForAnotherCommand = (lineStart !== undefined); 81 | await ManimShell.instance.executeStartCommand( 82 | command, isRequestedForAnotherCommand, shouldPreviewWholeScene); 83 | } 84 | 85 | /** 86 | * Force-quits the active Manim session by disposing the respective VSCode 87 | * terminal that is currently hosting the session. 88 | * 89 | * See `forceQuitActiveShell()` for more details. 90 | */ 91 | export async function exitScene() { 92 | try { 93 | ManimShell.instance.errorOnNoActiveShell(); 94 | await ManimShell.instance.forceQuitActiveShell(); 95 | } catch (error) { 96 | if (error instanceof NoActiveShellError) { 97 | Window.showWarningMessage("No active Manim session found to quit."); 98 | return; 99 | } 100 | Logger.error(`💥 Error while trying to exit the scene: ${error}`); 101 | throw error; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/utils/fileUtil.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as vscode from "vscode"; 4 | 5 | /** 6 | * Waits until a file exists on the disk. 7 | * 8 | * By https://stackoverflow.com/a/47764403/ 9 | * 10 | * @param filePath The path of the file to wait for. 11 | * @param timeout The maximum time to wait in milliseconds. 12 | */ 13 | export async function waitUntilFileExists(filePath: string, timeout: number): Promise { 14 | return new Promise(function (resolve, reject) { 15 | const timer = setTimeout(function () { 16 | watcher.close(); 17 | reject(); 18 | }, timeout); 19 | 20 | fs.access(filePath, fs.constants.R_OK, (err) => { 21 | if (!err) { 22 | clearTimeout(timer); 23 | watcher.close(); 24 | resolve(); 25 | } 26 | }); 27 | 28 | const dir = path.dirname(filePath); 29 | const basename = path.basename(filePath); 30 | const watcher = fs.watch(dir, function (eventType, filename) { 31 | if (eventType === "rename" && filename === basename) { 32 | clearTimeout(timer); 33 | watcher.close(); 34 | resolve(); 35 | } 36 | }); 37 | }); 38 | } 39 | 40 | /** 41 | * Opens a file in the OS file explorer. 42 | * 43 | * @param uri The URI of the file to reveal. 44 | */ 45 | export async function revealFileInOS(uri: vscode.Uri) { 46 | if (vscode.env.remoteName === "wsl") { 47 | await vscode.commands.executeCommand("remote-wsl.revealInExplorer", uri); 48 | } else { 49 | await vscode.commands.executeCommand("revealFileInOS", uri); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/multiStepQuickPickUtil.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | QuickInput, 4 | QuickInputButton, 5 | QuickInputButtons, 6 | QuickPickItem, window, 7 | } from "vscode"; 8 | 9 | export function toQuickPickItem(names: string): QuickPickItem { 10 | return { label: names }; 11 | } 12 | 13 | export function toQuickPickItems(names: string[]): QuickPickItem[] { 14 | return names.map(name => toQuickPickItem(name)); 15 | } 16 | 17 | export function shouldResumeNoOp() { 18 | return new Promise((_resolve, _reject) => { 19 | // noop 20 | }); 21 | } 22 | 23 | //////////////////////////////////////////////////////////////////////////////// 24 | 25 | /** 26 | * What follows is helper code that eases multi-step input flow in quick picks 27 | * by wrapping the VSCode API, e.g. it implements a stack to allow for back 28 | * navigation in the quick picks. 29 | * 30 | * This code is copied 1:1 from the VSCode extension samples [1]. We do not use 31 | * every feature of it, however it's designed flexibly enough that we can 32 | * pick and choose what we need. 33 | * 34 | * We could reduce the code size by removing unused features, but we leave it as 35 | * is for now to keep the code as close to the original example as possible. 36 | * If necessary, we might want to use more features of it in the future. 37 | * See it as kind of a library that should (IMHO) be included in the VSCode API 38 | * itself. 39 | * 40 | * 🛑 List of what was manually changed after copying the code: 41 | * - Export the `MultiStepInput` class. 42 | * 43 | * [1] https://github.com/microsoft/vscode-extension-samples/blob/6446fd7012bd1f7ef107683e09e488e943e668ef/quickinput-sample/src/multiStepInput.ts#L131C1-L314C2 44 | */ 45 | 46 | class InputFlowAction { 47 | static back = new InputFlowAction(); 48 | static cancel = new InputFlowAction(); 49 | static resume = new InputFlowAction(); 50 | } 51 | 52 | type InputStep = (_input: MultiStepInput) => Thenable; 53 | 54 | interface QuickPickParameters { 55 | title: string; 56 | step: number; 57 | totalSteps: number; 58 | items: T[]; 59 | activeItem?: T; 60 | ignoreFocusOut?: boolean; 61 | placeholder: string; 62 | buttons?: QuickInputButton[]; 63 | shouldResume: () => Thenable; 64 | } 65 | 66 | interface InputBoxParameters { 67 | title: string; 68 | step: number; 69 | totalSteps: number; 70 | value: string; 71 | prompt: string; 72 | validate: (_value: string) => Promise; 73 | buttons?: QuickInputButton[]; 74 | ignoreFocusOut?: boolean; 75 | placeholder?: string; 76 | shouldResume: () => Thenable; 77 | } 78 | 79 | export class MultiStepInput { 80 | static async run(start: InputStep) { 81 | const input = new MultiStepInput(); 82 | return input.stepThrough(start); 83 | } 84 | 85 | private current?: QuickInput; 86 | private steps: InputStep[] = []; 87 | 88 | private async stepThrough(start: InputStep) { 89 | let step: InputStep | void = start; 90 | while (step) { 91 | this.steps.push(step); 92 | if (this.current) { 93 | this.current.enabled = false; 94 | this.current.busy = true; 95 | } 96 | try { 97 | step = await step(this); 98 | } catch (err) { 99 | if (err === InputFlowAction.back) { 100 | this.steps.pop(); 101 | step = this.steps.pop(); 102 | } else if (err === InputFlowAction.resume) { 103 | step = this.steps.pop(); 104 | } else if (err === InputFlowAction.cancel) { 105 | step = undefined; 106 | } else { 107 | throw err; 108 | } 109 | } 110 | } 111 | if (this.current) { 112 | this.current.dispose(); 113 | } 114 | } 115 | 116 | async showQuickPick>( 117 | { title, step, totalSteps, items, activeItem, ignoreFocusOut, 118 | placeholder, buttons, shouldResume }: P) { 119 | const disposables: Disposable[] = []; 120 | try { 121 | return await 122 | new Promise((resolve, reject) => { 123 | const input = window.createQuickPick(); 124 | input.title = title; 125 | input.step = step; 126 | input.totalSteps = totalSteps; 127 | input.ignoreFocusOut = ignoreFocusOut ?? false; 128 | input.placeholder = placeholder; 129 | input.items = items; 130 | if (activeItem) { 131 | input.activeItems = [activeItem]; 132 | } 133 | input.buttons = [ 134 | ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), 135 | ...(buttons || []), 136 | ]; 137 | disposables.push( 138 | input.onDidTriggerButton((item) => { 139 | if (item === QuickInputButtons.Back) { 140 | reject(InputFlowAction.back); 141 | } else { 142 | resolve((item as any)); 143 | } 144 | }), 145 | input.onDidChangeSelection(items => resolve(items[0])), 146 | input.onDidHide(() => { 147 | (async () => { 148 | reject(shouldResume && await shouldResume() 149 | ? InputFlowAction.resume 150 | : InputFlowAction.cancel); 151 | })() 152 | .catch(reject); 153 | }), 154 | ); 155 | if (this.current) { 156 | this.current.dispose(); 157 | } 158 | this.current = input; 159 | this.current.show(); 160 | }); 161 | } finally { 162 | disposables.forEach(d => d.dispose()); 163 | } 164 | } 165 | 166 | async showInputBox

( 167 | { title, step, totalSteps, value, prompt, validate, 168 | buttons, ignoreFocusOut, placeholder, shouldResume }: P) { 169 | const disposables: Disposable[] = []; 170 | try { 171 | return await 172 | new Promise((resolve, reject) => { 173 | const input = window.createInputBox(); 174 | input.title = title; 175 | input.step = step; 176 | input.totalSteps = totalSteps; 177 | input.value = value || ""; 178 | input.prompt = prompt; 179 | input.ignoreFocusOut = ignoreFocusOut ?? false; 180 | input.placeholder = placeholder; 181 | input.buttons = [ 182 | ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), 183 | ...(buttons || []), 184 | ]; 185 | let validating = validate(""); 186 | disposables.push( 187 | input.onDidTriggerButton((item) => { 188 | if (item === QuickInputButtons.Back) { 189 | reject(InputFlowAction.back); 190 | } else { 191 | resolve(item as any); 192 | } 193 | }), 194 | input.onDidAccept(async () => { 195 | const value = input.value; 196 | input.enabled = false; 197 | input.busy = true; 198 | if (!(await validate(value))) { 199 | resolve(value); 200 | } 201 | input.enabled = true; 202 | input.busy = false; 203 | }), 204 | input.onDidChangeValue(async (text) => { 205 | const current = validate(text); 206 | validating = current; 207 | const validationMessage = await current; 208 | if (current === validating) { 209 | input.validationMessage = validationMessage; 210 | } 211 | }), 212 | input.onDidHide(() => { 213 | (async () => { 214 | reject(shouldResume && await shouldResume() 215 | ? InputFlowAction.resume 216 | : InputFlowAction.cancel); 217 | })() 218 | .catch(reject); 219 | }), 220 | ); 221 | if (this.current) { 222 | this.current.dispose(); 223 | } 224 | this.current = input; 225 | this.current.show(); 226 | }); 227 | } finally { 228 | disposables.forEach(d => d.dispose()); 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/utils/terminal.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { window } from "vscode"; 3 | 4 | /** 5 | * Waits a user-defined delay before allowing the terminal to be used. This 6 | * might be useful for some activation scripts to load like virtualenvs etc. 7 | * 8 | * Note that this function must be awaited by the caller, otherwise the delay 9 | * will not be respected. 10 | * 11 | * This function does not have any reference to the actual terminal, it just 12 | * waits, and that's it. 13 | */ 14 | export async function waitNewTerminalDelay() { 15 | const delay: number = await vscode.workspace 16 | .getConfiguration("manim-notebook").get("delayNewTerminal")!; 17 | 18 | if (delay > 600) { 19 | await window.withProgress({ 20 | location: vscode.ProgressLocation.Notification, 21 | title: "Waiting a user-defined delay for the new terminal...", 22 | cancellable: false, 23 | }, async (progress, _token) => { 24 | progress.report({ increment: 0 }); 25 | 26 | // split user-defined timeout into 500ms chunks and show progress 27 | const numChunks = Math.ceil(delay / 500); 28 | for (let i = 0; i < numChunks; i++) { 29 | await new Promise(resolve => setTimeout(resolve, 500)); 30 | progress.report({ increment: 100 / numChunks }); 31 | } 32 | }); 33 | } else { 34 | await new Promise(resolve => setTimeout(resolve, delay)); 35 | } 36 | } 37 | 38 | /** 39 | * Regular expression to match ANSI control sequences. Even though we might miss 40 | * some control sequences, this is good enough for our purposes as the relevant 41 | * ones are matched. From: https://stackoverflow.com/a/14693789/ 42 | */ 43 | const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; 44 | 45 | /** 46 | * Removes ANSI control codes from the given stream of strings and yields the 47 | * cleaned strings. 48 | * 49 | * @param stream The stream of strings to clean. 50 | * @returns An async iterable stream of strings without ANSI control codes. 51 | */ 52 | export async function* withoutAnsiCodes(stream: AsyncIterable): AsyncIterable { 53 | for await (const data of stream) { 54 | yield stripAnsiCodes(data); 55 | } 56 | } 57 | 58 | export function stripAnsiCodes(data: string): string { 59 | return data.replace(ANSI_CONTROL_SEQUENCE_REGEX, ""); 60 | } 61 | 62 | /** 63 | * Registers a callback to read the stdout from the terminal. The callback is 64 | * invoked whenever the given terminal emits output. The output is cleaned from 65 | * ANSI control codes by default. 66 | * 67 | * Note that you should execute the command in the terminal after registering 68 | * the callback, otherwise the callback might not be invoked. 69 | * 70 | * @param terminal The terminal to listen to. 71 | * @param callback The callback to invoke when output is emitted. 72 | * @param withoutAnsi Whether to clean the output from ANSI control codes. 73 | */ 74 | export async function onTerminalOutput( 75 | terminal: vscode.Terminal, callback: (_data: string) => void, withoutAnsi = true) { 76 | window.onDidStartTerminalShellExecution( 77 | async (event: vscode.TerminalShellExecutionStartEvent) => { 78 | if (event.terminal !== terminal) { 79 | return; 80 | } 81 | 82 | let stream = event.execution.read(); 83 | if (withoutAnsi) { 84 | stream = withoutAnsiCodes(stream); 85 | } 86 | 87 | for await (const data of stream) { 88 | callback(data); 89 | } 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/testing.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { Terminal, TerminalShellExecution, window } from "vscode"; 3 | import { stripAnsiCodes } from "./terminal"; 4 | 5 | export function setupTestEnvironment() { 6 | const basePath = process.env.TEST_BASE_PATH; 7 | if (!basePath) { 8 | throw new Error("TEST_BASE_PATH not set, although in testing environment."); 9 | } 10 | const binFolderName = process.platform === "win32" ? "Scripts" : "bin"; 11 | const binPath = path.join(basePath, "tmp", "manimVenv", binFolderName); 12 | injectPythonVenvIntoTerminals(binPath); 13 | injectPythonVenvIntoNodeExec(binPath); 14 | } 15 | 16 | /** 17 | * Injects the VSCode terminal such that every command sent to it is prefixed 18 | * with a given path. 19 | * 20 | * This is useful in a testing environment where we want to run commands in a 21 | * virtual Python environment without actually installing the Python extension 22 | * that would source the virtual environment for us. 23 | * 24 | * @param binPath The path to the bin folder of the virtual Python environment. 25 | * This path will be prefixed to every command sent to the terminal, e.g. 26 | * `manimgl --version` becomes 27 | * `/path/to/venv/bin/manimgl --version`. 28 | */ 29 | function injectPythonVenvIntoTerminals(binPath: string) { 30 | const createTerminalOrig = window.createTerminal; 31 | window.createTerminal = (args: any): Terminal => { 32 | const terminal = createTerminalOrig(args); 33 | 34 | // Inject sendText() 35 | const sendTextOrig = terminal.sendText; 36 | terminal.sendText = (text: string, shouldExecute?: boolean): void => { 37 | if (isManimCommand(text)) { 38 | return sendTextOrig.call(terminal, text, shouldExecute); 39 | } 40 | return sendTextOrig.call(terminal, path.join(binPath, text), shouldExecute); 41 | }; 42 | 43 | // Inject shellIntegration.executeCommand() 44 | window.onDidChangeTerminalShellIntegration((event) => { 45 | if (event.terminal !== terminal) { 46 | return; 47 | } 48 | const shellIntegration = event.shellIntegration; 49 | // only stub executeCommand(commandLine: string) method, not 50 | // the executeCommand(executable: string, args: string[]) method 51 | const executeCommandOrig = shellIntegration.executeCommand as 52 | (_commandLine: string) => TerminalShellExecution; 53 | shellIntegration.executeCommand = (commandLine: string): TerminalShellExecution => { 54 | if (isManimCommand(commandLine)) { 55 | return executeCommandOrig.call(shellIntegration, commandLine); 56 | } 57 | return executeCommandOrig.call(shellIntegration, path.join(binPath, commandLine)); 58 | }; 59 | 60 | // Overwrite the readonly shellIntegration property of the terminal 61 | Object.defineProperty(terminal, "shellIntegration", { 62 | value: shellIntegration, 63 | writable: false, 64 | }); 65 | }); 66 | 67 | return terminal; 68 | }; 69 | } 70 | 71 | /** 72 | * Injects the Node.js child_process.exec() function such that every command 73 | * sent to it is prefixed with a given path. 74 | * 75 | * @param binPath The path to the bin folder of the virtual Python environment. 76 | * This path will be prefixed to every command sent to the terminal, e.g. 77 | * `manimgl --version` becomes `/path/to/venv/bin/manimgl --version`. 78 | */ 79 | function injectPythonVenvIntoNodeExec(binPath: string) { 80 | const execOrig = require("child_process").exec; 81 | 82 | require("child_process").exec = (command: string, options: any, callback: any) => { 83 | return execOrig(path.join(binPath, command), options, callback); 84 | }; 85 | } 86 | 87 | /** 88 | * Returns whether the given command is a Manim command used inside the Manim 89 | * interactive terminal (IPython shell). 90 | * 91 | * @param command The command to check. 92 | */ 93 | function isManimCommand(command: string): boolean { 94 | if (command.length <= 3) { 95 | return false; 96 | } 97 | const manimCommands = ["checkpoint_paste", "reload", "exit", "clear"]; 98 | command = command.trim(); 99 | command = stripAnsiCodes(command); 100 | return manimCommands.some(manimCommand => command.includes(`${manimCommand}(`)); 101 | } 102 | -------------------------------------------------------------------------------- /src/utils/venv.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | /** 4 | * Regular expression to match Python executables on Windows and Unix-like 5 | * systems. Will detect "python", "python3", "python.exe", and "python3.exe". 6 | */ 7 | const PYTHON_BINARY_REGEX = /python3?(\.exe)?$/; 8 | 9 | /** 10 | * Transforms a path pointing to either an environment folder or a 11 | * Python executable into a path pointing to the respective binary in that 12 | * environment. 13 | * 14 | * This is necessary since the Python extension may return the path to the 15 | * Python executable OR the path to the environment folder, depending on how 16 | * the venv was originally created. See: 17 | * https://github.com/microsoft/vscode-python/wiki/Python-Environment-APIs#extension-api-usage 18 | * 19 | * @param envPath The path to the Python environment or Python executable. 20 | * @param binary The binary to be called, e.g. "manimgl". 21 | * @returns The path to the binary inside the environment. 22 | */ 23 | export function getBinaryPathInPythonEnv(envPath: string, binary: string): string { 24 | if (PYTHON_BINARY_REGEX.test(envPath)) { 25 | return envPath.replace(PYTHON_BINARY_REGEX, binary); 26 | } 27 | 28 | const binFolderName = process.platform === "win32" ? "Scripts" : "bin"; 29 | return path.join(envPath, binFolderName, binary); 30 | } 31 | -------------------------------------------------------------------------------- /src/walkthrough.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import * as vscode from "vscode"; 4 | import { commands, ExtensionContext, window, workspace } from "vscode"; 5 | import { Logger } from "./logger"; 6 | 7 | export function registerWalkthroughCommands(context: ExtensionContext) { 8 | const openSampleFileCommand = commands.registerCommand( 9 | "manim-notebook-walkthrough.openSample", async () => { 10 | Logger.info("💠 Command Open Sample File requested"); 11 | await openSampleFile(context); 12 | }); 13 | 14 | const showAllCommandsCommand = commands.registerCommand( 15 | "manim-notebook-walkthrough.showCommands", async () => { 16 | Logger.info("💠 Command Show All Commands requested"); 17 | await showAllCommands(); 18 | }, 19 | ); 20 | 21 | const showKeyboardShortcutsCommand = commands.registerCommand( 22 | "manim-notebook-walkthrough.showShortcuts", async () => { 23 | Logger.info("💠 Command Show Keyboard Shortcuts requested"); 24 | await showKeyboardShortcuts(); 25 | }, 26 | ); 27 | 28 | const showSettingsCommand = commands.registerCommand( 29 | "manim-notebook-walkthrough.showSettings", async () => { 30 | Logger.info("💠 Command Show Settings requested"); 31 | await showSettings(); 32 | }, 33 | ); 34 | 35 | const openWikiCommand = commands.registerCommand( 36 | "manim-notebook-walkthrough.openWiki", async () => { 37 | Logger.info("💠 Command Open Wiki requested"); 38 | await openWiki(); 39 | }, 40 | ); 41 | 42 | context.subscriptions.push( 43 | openSampleFileCommand, 44 | showAllCommandsCommand, 45 | showKeyboardShortcutsCommand, 46 | showSettingsCommand, 47 | openWikiCommand, 48 | ); 49 | } 50 | 51 | /** 52 | * Opens a sample Manim file in a new editor that users can use to get started. 53 | * 54 | * @param context The extension context. 55 | */ 56 | async function openSampleFile(context: ExtensionContext) { 57 | const sampleFilePath = path.join(context.extensionPath, 58 | "assets", "walkthrough", "sample_scene.py"); 59 | const sampleFileContent = fs.readFileSync(sampleFilePath, "utf-8"); 60 | 61 | const sampleFile = await workspace.openTextDocument({ 62 | language: "python", 63 | content: sampleFileContent, 64 | }); 65 | 66 | await window.showTextDocument(sampleFile); 67 | } 68 | 69 | /** 70 | * Opens the command palette with all the "Manim Notebook" commands. 71 | */ 72 | async function showAllCommands() { 73 | await commands.executeCommand("workbench.action.quickOpen", ">Manim Notebook:"); 74 | } 75 | 76 | /** 77 | * Opens the keyboard shortcuts page in the settings. 78 | */ 79 | async function showKeyboardShortcuts() { 80 | await commands.executeCommand( 81 | "workbench.action.openGlobalKeybindings", "Manim Notebook"); 82 | } 83 | 84 | /** 85 | * Opens the settings page for the extension. 86 | */ 87 | async function showSettings() { 88 | await commands.executeCommand( 89 | "workbench.action.openSettings", "Manim Notebook"); 90 | } 91 | 92 | /** 93 | * Opens the GitHub wiki page for the extension. 94 | */ 95 | async function openWiki() { 96 | const wikiUrl = "https://github.com/Manim-Notebook/manim-notebook/wiki"; 97 | await vscode.env.openExternal(vscode.Uri.parse(wikiUrl)); 98 | } 99 | -------------------------------------------------------------------------------- /tests/activation.test.ts: -------------------------------------------------------------------------------- 1 | import { commands, window } from "vscode"; 2 | 3 | import { afterEach, before, describe, it } from "mocha"; 4 | import * as sinon from "sinon"; 5 | let expect: Chai.ExpectStatic; 6 | 7 | import { manimNotebookContext } from "../src/extension"; 8 | import { Logger } from "../src/logger"; 9 | import { applyWindowsPastePatch } from "../src/patches/applyPatches"; 10 | 11 | import { onTerminalOutput } from "../src/utils/terminal"; 12 | 13 | const MANIM_VERSION_STRING_REGEX = /v\d+\.\d+\.\d+/; 14 | 15 | before(async () => { 16 | // why this weird import syntax? 17 | // -> see https://github.com/microsoft/vscode/issues/130367 18 | const chai = await import("chai"); 19 | expect = chai.expect; 20 | }); 21 | 22 | describe("Manim Activation", function () { 23 | afterEach(() => { 24 | sinon.restore(); 25 | }); 26 | 27 | it("Reads `manimgl --version` from terminal", async () => { 28 | console.log("Creating terminal"); 29 | const terminal = window.createTerminal("Dummy terminal"); 30 | terminal.show(); 31 | 32 | return new Promise((resolve) => { 33 | onTerminalOutput(terminal, (data) => { 34 | console.log(data); 35 | if (MANIM_VERSION_STRING_REGEX.test(data)) { 36 | resolve(); 37 | } 38 | }); 39 | terminal.sendText("manimgl --version"); 40 | }); 41 | }); 42 | 43 | it("Redetects Manim version", async () => { 44 | // TODO: Test different Manim versions installed 45 | const spy = sinon.spy(window, "showInformationMessage"); 46 | await commands.executeCommand("manim-notebook.redetectManimVersion"); 47 | sinon.assert.called(spy); 48 | sinon.assert.calledWith(spy, sinon.match(MANIM_VERSION_STRING_REGEX)); 49 | }); 50 | 51 | it("Applies Windows paste patch", async function () { 52 | if (process.platform !== "win32") { 53 | this.skip(); 54 | } 55 | expect(manimNotebookContext).to.not.be.undefined; 56 | this.timeout(5000); 57 | 58 | const spyInfo = sinon.spy(Logger, "info"); 59 | const spyError = sinon.spy(Logger, "error"); 60 | 61 | applyWindowsPastePatch(manimNotebookContext, "python.exe"); 62 | 63 | await new Promise((resolve) => { 64 | const checkSpy = () => { 65 | sinon.assert.notCalled(spyError); 66 | if (spyInfo.calledWith("Windows paste patch successfully applied (in applyPatches.ts)")) { 67 | resolve(); 68 | } else { 69 | // we use a polling mechanism here as the patch is run 70 | // in the background 71 | setTimeout(checkSpy, 300); 72 | } 73 | }; 74 | checkSpy(); 75 | }); 76 | sinon.assert.notCalled(spyError); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /tests/cellRanges.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "mocha"; 2 | import { Range, workspace } from "vscode"; 3 | import { ManimCellRanges } from "../src/pythonParsing"; 4 | import { uriInWorkspace } from "./utils/testRunner"; 5 | 6 | describe("Manim Cell Ranges", function () { 7 | // in the expected ranges we only care about the start and end lines 8 | // line numbers are 0-based here 9 | const tests = [ 10 | { 11 | filename: "detection_basic.py", 12 | expectedRanges: [[5, 7], [9, 10], [16, 18]], 13 | }, 14 | { 15 | filename: "detection_class_definition.py", 16 | expectedRanges: [[9, 11], [13, 14]], 17 | }, 18 | { 19 | filename: "detection_inside_construct.py", 20 | expectedRanges: [[5, 12], [14, 16]], 21 | }, 22 | { 23 | filename: "detection_multiple_inheritance.py", 24 | expectedRanges: [[5, 7], [9, 21], [14, 21], [31, 33], [35, 36]], 25 | }, 26 | { 27 | filename: "detection_multiple_methods.py", 28 | expectedRanges: [[5, 7], [9, 10]], 29 | }, 30 | { 31 | filename: "detection_syntax.py", 32 | expectedRanges: [[5, 9], [11, 12], [14, 15], [17, 17], [19, 19], 33 | [25, 25], [31, 32], [33, 34], [35, 35], [36, 36]], 34 | }, 35 | ]; 36 | 37 | tests.forEach(function (test) { 38 | it(`Correctly assigns ranges for ${test.filename}`, async () => { 39 | const uri = uriInWorkspace(test.filename); 40 | const document = await workspace.openTextDocument(uri); 41 | 42 | const ranges: Range[] = ManimCellRanges.calculateRanges(document); 43 | const expectedRanges = test.expectedRanges; 44 | 45 | if (ranges.length !== expectedRanges.length) { 46 | throw new Error(`Expected ${expectedRanges.length} ranges, but got ${ranges.length}`); 47 | } 48 | 49 | for (let i = 0; i < ranges.length; i++) { 50 | const range = ranges[i]; 51 | const expectedRange = expectedRanges[i]; 52 | 53 | if (range.start.line !== expectedRange[0]) { 54 | throw new Error(`Start line does not match (${i}):` 55 | + ` expected: ${expectedRange[0]}, actual: ${range.start.line}`); 56 | } 57 | if (range.end.line !== expectedRange[1]) { 58 | throw new Error(`End line does not match (${i}):` 59 | + ` expected: ${expectedRange[1]}, actual: ${range.end.line}`); 60 | } 61 | } 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/fixtures/basic.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class MyFirstManimNotebook(Scene): 5 | def construct(self): 6 | ## Create a circle 7 | circle = Circle() 8 | circle.set_stroke(BLUE_E, width=4) 9 | self.play(ShowCreation(circle)) 10 | 11 | ## Transform circle to square 12 | square = Square() 13 | self.play(ReplacementTransform(circle, square)) 14 | 15 | ## Make it red & fly away 16 | self.play( 17 | square.animate.set_fill(RED_D, opacity=0.5), 18 | self.frame.animate.set_width(25), 19 | ) 20 | -------------------------------------------------------------------------------- /tests/fixtures/crazy.py: -------------------------------------------------------------------------------- 1 | class ManimScene: 2 | def setup(self): 3 | self.my_variable = 42 4 | 5 | def construct(self): 6 | a = 5 7 | 8 | class InnerClass: 9 | def __init__(self): 10 | self.a = 5 11 | 12 | def inner_function(self): 13 | ## A Manim Cell 14 | a = 6 15 | b = 3 16 | 17 | # comment 18 | print(a + b) 19 | 20 | def this_is(): 21 | def very(): 22 | ## A cell here ;) 23 | def nested(): 24 | # print() 25 | # print() 26 | # print() 27 | def even_more(): 28 | print("Hello world") 29 | 30 | return nested 31 | 32 | return very 33 | 34 | print(this_is()) 35 | 36 | # some comments 37 | 38 | ## A Manim Cell 39 | a = 6 40 | b = 3 41 | c = 42 42 | 43 | def inline_function(): 44 | a = 5 45 | b = 6 46 | c = 7 47 | return a + b + c 48 | 49 | d = 5 50 | e = 6 51 | f = 8 52 | 53 | ## Another Manim cell 54 | a = 6 55 | b = 3 56 | 57 | # Important 58 | c = 42 59 | d = 5 60 | e = 6 61 | f = 8 62 | 63 | ## Another one 64 | # Here 65 | def inside(): 66 | ## nested cells are not supported 67 | a = 5 68 | 69 | def another_function(self): 70 | print("Hello world") 71 | -------------------------------------------------------------------------------- /tests/fixtures/detection_basic.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class BasicNotebook(Scene): 5 | def construct(self): 6 | ## A Manim Cell 7 | print("With some code") 8 | print("With some more code") 9 | 10 | ## And another Manim Cell 11 | print("And even more code") 12 | 13 | 14 | class BasicNotebookWithType(Scene): 15 | 16 | def construct(self) -> None: 17 | ## Cell inside construct(self) marked with "None" type 18 | print("With some code None") 19 | print("With some more code None") 20 | 21 | 22 | class NoManimScene(Scene): 23 | def constructtttt(self): 24 | ## Should not be detected as Manim Cell 25 | print("Hi there") 26 | 27 | 28 | class WithoutAnyManimCells(Scene): 29 | def construct(self): 30 | print("We have no ManimCell in here") 31 | print("And therefore also no ManimCell is detected") 32 | -------------------------------------------------------------------------------- /tests/fixtures/detection_class_definition.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class AnythingElse: 5 | pass 6 | 7 | 8 | class Inheritance(AnythingElse): 9 | def construct(self): 10 | ## A Manim Cell 11 | print("With some code") 12 | print("With some more code") 13 | 14 | ## And another Manim Cell 15 | print("And even more code") 16 | 17 | 18 | class InheritanceNothing(): # don't remove "()" here (!) 19 | def construct(self): 20 | ## Should not be detected as Manim Cell 21 | print("With some code") 22 | print("With some more code") 23 | 24 | ## Should neither be detected as Manim Cell 25 | print("And even more code") 26 | 27 | 28 | class InheritanceNothingWithoutParentheses: 29 | def construct(self): 30 | ## Should not be detected as Manim Cell 31 | print("With some code") 32 | print("With some more code") 33 | 34 | ## Should neither be detected as Manim Cell 35 | print("And even more code") 36 | -------------------------------------------------------------------------------- /tests/fixtures/detection_inside_construct.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class SceneDetectionInsideConstruct(Scene): 5 | def construct(self): 6 | ## A Manim Cell 7 | def some_function(): 8 | ## Not a Manim Cell, instead part of the outer one 9 | print("Not a Manim Cell") 10 | 11 | print("With some code") 12 | some_function() 13 | print("With some more code") 14 | 15 | ## And another Manim Cell 16 | print("And even more code") 17 | some_function() 18 | -------------------------------------------------------------------------------- /tests/fixtures/detection_multiple_inheritance.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class BasicNotebook(Scene): 5 | def construct(self): 6 | ## A Manim Cell 7 | print("With some code") 8 | print("With some more code") 9 | 10 | ## And another Manim Cell 11 | print("And even more code") 12 | 13 | class AnInvalidManimScene(Scene): 14 | def construct(self): 15 | ## Not a Manim Cell, but still detected as one 16 | # We mark this as undefined behavior and Manim does not support 17 | # a scene inside another scene. 18 | # We still consider this as a Manim Cell since detecting this 19 | # case would be too cumbersome to be worth it to consider. 20 | # The user will find out in Manim itself that this is not 21 | # supported. 22 | print("Not a valid Manim Scene") 23 | 24 | 25 | class BaseClass(Scene): 26 | def setup(self): 27 | print("Setting up") 28 | 29 | 30 | class SubClass(BaseClass): 31 | def construct(self): 32 | ## A Manim Cell in a subclass 33 | print("Constructing") 34 | print("Hey there") 35 | 36 | ## Another Manim Cell in a subclass 37 | print("Constructing more") 38 | -------------------------------------------------------------------------------- /tests/fixtures/detection_multiple_methods.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class MultipleMethods(Scene): 5 | def construct(self): 6 | ## A Manim Cell 7 | print("With some code") 8 | print("With some more code") 9 | 10 | ## And another Manim Cell 11 | print("And even more code") 12 | 13 | def another_construct(self): 14 | ## Should not be detected as Manim Cell 15 | print("Hi there from another construct method") 16 | -------------------------------------------------------------------------------- /tests/fixtures/detection_syntax.py: -------------------------------------------------------------------------------- 1 | from manimlib import * 2 | 3 | 4 | class Syntax(Scene): 5 | def construct(self): 6 | ## A Manim Cell 7 | print("Hello, Manim!") 8 | 9 | # Not a Manim Cell, just a regular comment 10 | print("Not a Manim Cell") 11 | 12 | ### A Manim Cell 13 | print("Hello, Manim!") 14 | 15 | ########## # # # # A Manim Cell 16 | print("Hello, Manim!") 17 | 18 | ## A Manim Cell without any code 19 | 20 | ## Another empty Manim Cell 21 | 22 | 23 | class SyntaxNotAtStart(Scene): 24 | def construct(self): 25 | print("test") 26 | ## Empty Manim Cell, should start here, not at the line before 27 | 28 | 29 | class WithoutNewLine(Scene): 30 | def construct(self): 31 | print("test") 32 | ## First ManimCell 33 | print("yeah") 34 | ## Second ManimCell 35 | print("yeah2") 36 | ## Third ManimCell 37 | ## Fourth ManimCell 38 | -------------------------------------------------------------------------------- /tests/fixtures/laggy.py: -------------------------------------------------------------------------------- 1 | # from https://github.com/Manim-Notebook/manim-notebook/issues/18#issuecomment-2431224967 2 | from manimlib import * 3 | 4 | 5 | class Laggy(Scene): 6 | 7 | def construct(self): 8 | ## Create some boxes 9 | grid = Square().get_grid(35, 35, buff=0) 10 | grid.set_height(5) 11 | grid.set_stroke(WHITE) 12 | self.play(ShowCreation(grid)) 13 | 14 | ## Move the boxes 15 | position = ValueTracker(0) 16 | grid.add_updater( 17 | lambda m, position=position: m.move_to((position.get_value(), 0, 0)) 18 | ) 19 | self.play(position.animate.set_value(2), run_time=5) 20 | 21 | ## Move boxes back 22 | self.play(position.animate.set_value(0), run_time=5) 23 | -------------------------------------------------------------------------------- /tests/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry point for tests. Uses the @vscode/test-electron API to simplify 3 | * the process of downloading, unzipping, and launching VSCode with extension 4 | * test parameters. 5 | * 6 | * This is only used when you call "npm test" in the terminal and not when you 7 | * run the tests from the debug panel. For the latter, we have a custom 8 | * launch task in .vscode/launch.json. 9 | * 10 | * Adapted from the VSCode testing extension guide [1]. 11 | * File on Github: [2]. 12 | * 13 | * [1] https://code.visualstudio.com/api/working-with-extensions/testing-extension#advanced-setup-your-own-runner 14 | * [2] https://github.com/microsoft/vscode-extension-samples/blob/main/helloworld-test-sample/src/test/runTest.ts 15 | */ 16 | 17 | import * as path from "path"; 18 | 19 | import { runTests } from "@vscode/test-electron"; 20 | 21 | /** 22 | * Main entry point for tests from the "npm test" script. This is not called 23 | * when you run the tests from the debug panel via the custom launch task in 24 | * .vscode/launch.json. 25 | */ 26 | async function main() { 27 | try { 28 | // Folder containing the extension manifest package.json 29 | // Passed to `--extensionDevelopmentPath` 30 | const extensionDevelopmentPath = path.resolve(__dirname, "../../"); 31 | 32 | // Path to the extension test script 33 | // Passed to --extensionTestsPath 34 | const extensionTestsPath = path.resolve(__dirname, "./utils/testRunner"); 35 | 36 | // Download VS Code, unzip it and run the integration test 37 | await runTests({ 38 | extensionDevelopmentPath, 39 | extensionTestsPath, 40 | launchArgs: [ 41 | "tests/fixtures", 42 | "--disable-extensions", 43 | ], 44 | // handled in the testRunner 45 | extensionTestsEnv: { 46 | IS_CALLED_IN_NPM_SCRIPT: "true", 47 | EXTENSION_DEV_PATH: extensionDevelopmentPath, 48 | }, 49 | }); 50 | } catch { 51 | console.error("Failed to run tests"); 52 | process.exit(1); 53 | } 54 | } 55 | 56 | main(); 57 | -------------------------------------------------------------------------------- /tests/preview.test.ts: -------------------------------------------------------------------------------- 1 | import { before, describe, it } from "mocha"; 2 | import { commands, window } from "vscode"; 3 | import { goToLine } from "./utils/editor"; 4 | import { onAnyTerminalOutput } from "./utils/terminal"; 5 | import { uriInWorkspace } from "./utils/testRunner"; 6 | let expect: Chai.ExpectStatic; 7 | 8 | before(async () => { 9 | // why this weird import syntax? 10 | // -> see https://github.com/microsoft/vscode/issues/130367 11 | const chai = await import("chai"); 12 | expect = chai.expect; 13 | }); 14 | 15 | describe("Previewing", function () { 16 | it("Can preview the current Manim Cell", async () => { 17 | const editor = await window.showTextDocument(uriInWorkspace("basic.py")); 18 | goToLine(editor, 11); 19 | await commands.executeCommand("manim-notebook.previewManimCell"); 20 | 21 | return new Promise((resolve) => { 22 | onAnyTerminalOutput(async (data, stopListening) => { 23 | if (data.includes("ReplacementTransformCircle")) { 24 | stopListening(); 25 | await commands.executeCommand("manim-notebook.exitScene"); 26 | setTimeout(resolve, 1500); 27 | } 28 | }); 29 | }); 30 | }); 31 | 32 | it("Can preview laggy scene", async () => { 33 | const editor = await window.showTextDocument(uriInWorkspace("laggy.py")); 34 | const queue: { line: number; waitForStrings: string[]; resolve: () => void }[] = []; 35 | let wantToStopListening = false; 36 | 37 | onAnyTerminalOutput(async (data, stopListening) => { 38 | if (wantToStopListening) { 39 | stopListening(); 40 | return; 41 | } 42 | if (queue.length === 0) { 43 | throw new Error("Listening to terminal output, but nothing in queue to check against"); 44 | } 45 | 46 | const { waitForStrings } = queue[0]; 47 | for (const str of waitForStrings) { 48 | if (data.includes(str)) { 49 | waitForStrings.removeByValue(str); 50 | } 51 | } 52 | if (waitForStrings.length === 0) { 53 | queue.shift()?.resolve(); 54 | } 55 | }); 56 | 57 | async function testPreviewAtLine(line: number, waitForStrings: string[]) { 58 | goToLine(editor, line); 59 | await commands.executeCommand("manim-notebook.previewManimCell"); 60 | await new Promise((resolve) => { 61 | queue.push({ line, waitForStrings, resolve }); 62 | }); 63 | } 64 | 65 | await testPreviewAtLine(8, ["ShowCreationVGroup", "In [2]:"]); 66 | await testPreviewAtLine(14, ["_MethodAnimationValueTracker", "In [3]:"]); 67 | await testPreviewAtLine(21, ["_MethodAnimationValueTracker", "In [4]:"]); 68 | await testPreviewAtLine(14, ["_MethodAnimationValueTracker", "In [5]:"]); 69 | 70 | wantToStopListening = true; // cleanup for subsequent tests 71 | expect(queue.length).to.equal(0); 72 | 73 | await commands.executeCommand("manim-notebook.exitScene"); 74 | await new Promise(resolve => setTimeout(resolve, 1000)); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/scripts/setTestEntryPoint.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const packageJsonPath = path.resolve(__dirname, "../../package.json"); 5 | const packageJson = require(packageJsonPath); 6 | 7 | function setTestEntryPointForTest() { 8 | packageJson.main = "./out-test/src/extension.js"; 9 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); 10 | } 11 | 12 | function resetEntryPoint() { 13 | packageJson.main = "./dist/extension.js"; 14 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); 15 | } 16 | 17 | if (process.argv[2] === "set") { 18 | setTestEntryPointForTest(); 19 | } else if (process.argv[2] === "reset") { 20 | resetEntryPoint(); 21 | } 22 | -------------------------------------------------------------------------------- /tests/utils/editor.ts: -------------------------------------------------------------------------------- 1 | import { Selection, TextEditor } from "vscode"; 2 | 3 | /** 4 | * Moves the cursor to the specified line number. 5 | * 6 | * @param editor The editor to move the cursor in. 7 | * @param lineNumber The line number to move the cursor to (1-indexed). 8 | * 9 | * Adapted from: 10 | * https://github.com/microsoft/vscode/issues/6695#issuecomment-221146568 11 | */ 12 | export function goToLine(editor: TextEditor, lineNumber: number) { 13 | let range = editor.document.lineAt(lineNumber - 1).range; 14 | editor.selection = new Selection(range.start, range.end); 15 | editor.revealRange(range); 16 | } 17 | -------------------------------------------------------------------------------- /tests/utils/installManim.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { ManimInstaller } from "./manimInstaller"; 3 | 4 | async function setupManimInstallation() { 5 | const baseFolder = process.cwd(); 6 | const tmpFolder = path.join(baseFolder, "tmp"); 7 | console.log(`Temporary folder: ${tmpFolder}`); 8 | 9 | const installer = new ManimInstaller(); 10 | await installer.setup(tmpFolder); 11 | await installer.download(); 12 | await installer.install(); 13 | await installer.installAdditionalDependencies(); 14 | await installer.verifyInstallation(); 15 | 16 | console.log("💫 If started via debug configuration," 17 | + "go to the VSCode debug console to see the test output."); 18 | } 19 | 20 | (async () => { 21 | await setupManimInstallation(); 22 | })(); 23 | -------------------------------------------------------------------------------- /tests/utils/manimCaller.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { window } from "vscode"; 3 | 4 | export class ManimCaller { 5 | public venvPath: string = ""; 6 | 7 | async callManim(args: string) { 8 | if (!this.venvPath) { 9 | throw new Error("Python virtual environment path not set (in ManimCaller)."); 10 | } 11 | 12 | const terminal = await window.createTerminal("Manim"); 13 | terminal.show(); 14 | 15 | const activatePath = path.join(this.venvPath, "bin", "activate"); 16 | console.log(`▶️ Calling Manimgl ${args}`); 17 | terminal.sendText(`. ${activatePath} && manimgl ${args}`); 18 | await new Promise(resolve => setTimeout(resolve, 5000)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/utils/manimInstaller.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { existsSync, mkdirSync } from "fs"; 3 | import * as path from "path"; 4 | 5 | function run(cmd: string, ...args: any): Promise { 6 | const promise = new Promise((resolve, reject) => { 7 | console.log(`▶️ Running command: ${cmd}`); 8 | exec(cmd, ...args, (error: Error, stdout: string | Buffer, stderr: string | Buffer) => { 9 | let errorMessage = error?.message || stderr; 10 | if (errorMessage) { 11 | if (errorMessage.includes("ffmpeg")) { 12 | console.error("🔥 ffmpeg warning detected -> will be ignored:"); 13 | console.error(errorMessage); 14 | } else if (errorMessage.includes("VK_ERROR_INCOMPATIBLE_DRIVER")) { 15 | // https://gitlab.freedesktop.org/mesa/mesa/-/issues/10293 16 | // MESA: error: 17 | // ZINK: vkCreateInstance failed (VK_ERROR_INCOMPATIBLE_DRIVER) 18 | console.error("🔥 ZINK: vkCreateInstance warning detected -> will be ignored:"); 19 | console.error(errorMessage); 20 | } else { 21 | console.error("🔥 Error while running command"); 22 | if (error) return reject(error); 23 | if (stderr) return reject(stderr); 24 | } 25 | } 26 | console.log(stdout); 27 | resolve(stdout); 28 | }); 29 | }); 30 | promise.catch((err) => { 31 | console.error("Caught error in promise"); 32 | console.error(err); 33 | }); 34 | return promise; 35 | } 36 | 37 | /** 38 | * Helper class to install Manim and set up the Python virtual environment. 39 | * We expect that the setup() method is the first one to be called. 40 | */ 41 | export class ManimInstaller { 42 | /** 43 | * Path to the path where Manim will be installed to. 44 | */ 45 | private manimPath: string = ""; 46 | 47 | /** 48 | * Path to the virtual Python environment. 49 | */ 50 | private venvPath: string = ""; 51 | 52 | /** 53 | * Name of the Python binary. 54 | */ 55 | private pythonBinary = process.platform === "win32" ? "python" : "python3"; 56 | 57 | /** 58 | * Sets up the Manim installation path. 59 | * 60 | * @param tmpFolder The path for a temporary folder where we can install 61 | * Manim and the Python virtual environment to. 62 | */ 63 | public async setup(tmpFolder: string) { 64 | console.log("🎈 SETTING UP MANIM INSTALLATION"); 65 | 66 | // Manim installation path 67 | this.manimPath = path.join(tmpFolder, "manim"); 68 | if (!existsSync(this.manimPath)) { 69 | mkdirSync(this.manimPath, { recursive: true }); 70 | } 71 | 72 | console.log(`🍭 Manim installation path: ${this.manimPath}`); 73 | 74 | // Python virtual environment path 75 | this.venvPath = path.join(tmpFolder, "manimVenv"); 76 | console.log(`🍭 Python virtual environment path: ${this.venvPath}`); 77 | await run(`${this.pythonBinary} -m venv ${this.venvPath}`); 78 | await this.runWithVenvBin(`${this.pythonBinary} --version`); 79 | await this.runWithVenvBin("pip config set global.disable-pip-version-check true"); 80 | } 81 | 82 | /** 83 | * Checks if Manim is already downloaded (just a rudimentary check). 84 | */ 85 | private async isAlreadyDownloaded() { 86 | const exists = existsSync(this.manimPath); 87 | if (!exists) { 88 | return false; 89 | } 90 | const files = await run(`ls -A ${this.manimPath}`); 91 | return files.length > 0; 92 | } 93 | 94 | /** 95 | * Downloads Manim from the official repository if not already done. 96 | */ 97 | public async download() { 98 | if (await this.isAlreadyDownloaded()) { 99 | console.log("🎁 Manim already downloaded"); 100 | return; 101 | } 102 | 103 | console.log("🎁 Downloading Manim... (this might take a while)"); 104 | // 2>&1 redirects stderr to stdout since git writes to stderr for 105 | // diagnostic messages and we don't want to reject the promise in that case. 106 | await run(`git clone --depth 1 https://github.com/3b1b/manim.git ${this.manimPath} 2>&1`, 107 | { cwd: this.manimPath }); 108 | } 109 | 110 | /** 111 | * Installs Manim as (editable) Python package. 112 | */ 113 | public async install() { 114 | const pipList = await this.runWithVenvBin("pip list"); 115 | if (pipList.toLowerCase().includes("manimgl")) { 116 | console.log("❇️ Manim already installed via pip"); 117 | return; 118 | } 119 | console.log("❇️ Installing Manim..."); 120 | await this.runWithVenvBin(`pip install -e ${this.manimPath}`); 121 | console.log("❇️ Manim successfully installed"); 122 | } 123 | 124 | /** 125 | * Installs additional dependencies for Manim. 126 | */ 127 | public async installAdditionalDependencies() { 128 | console.log("🔧 Installing additional dependencies..."); 129 | await this.runWithVenvBin("pip install setuptools"); 130 | 131 | const pythonVersion = await this.runWithVenvBin(`${this.pythonBinary} --version`); 132 | if (pythonVersion.includes("3.13")) { 133 | // https://github.com/jiaaro/pydub/issues/815 134 | await this.runWithVenvBin("pip install audioop-lts"); 135 | } 136 | 137 | if (process.platform === "linux") { 138 | await this.runWithVenvBin("pip install PyOpenGL"); 139 | } 140 | 141 | console.log("🔧 Additional dependencies successfully installed"); 142 | } 143 | 144 | /** 145 | * Verifies the Manim installation. 146 | */ 147 | public async verifyInstallation() { 148 | console.log("🔍 Verifying Manim installation"); 149 | await this.runWithVenvBin("manimgl --version"); 150 | } 151 | 152 | /** 153 | * Runs a command using the respective binary from the Python virtual 154 | * environment. 155 | * 156 | * @param binPath The path to the bin folder of the virtual Python 157 | * environment. This path will be prefixed to every command, e.g. 158 | * `manimgl --version` becomes 159 | * `/path/to/venv/bin/manimgl --version`. 160 | */ 161 | private runWithVenvBin(cmd: string): Promise { 162 | if (!this.venvPath) { 163 | throw new Error("Python virtual environment not set up yet."); 164 | } 165 | const binFolderName = process.platform === "win32" ? "Scripts" : "bin"; 166 | const binPath = path.join(this.venvPath, binFolderName); 167 | return run(path.join(binPath, cmd)); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /tests/utils/prototype.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Array { 3 | removeByValue(_value: T): Array; 4 | } 5 | } 6 | 7 | /** 8 | * Removes the first occurrence of the specified value from the array. 9 | * 10 | * From https://stackoverflow.com/a/5767332/9655481 11 | * 12 | * @param value The value to remove. 13 | */ 14 | Array.prototype.removeByValue = function (value: any) { 15 | for (var i = 0; i < this.length; i++) { 16 | if (this[i] === value) { 17 | this.splice(i, 1); 18 | i--; 19 | } 20 | } 21 | return this; 22 | }; 23 | -------------------------------------------------------------------------------- /tests/utils/terminal.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { window } from "vscode"; 3 | import { withoutAnsiCodes } from "../../src/utils/terminal"; 4 | 5 | /** 6 | * Registers a callback to read the stdout from the terminal. The callback is 7 | * invoked whenever any terminal emits output. The output is cleaned from 8 | * ANSI control codes by default. 9 | * 10 | * This function should only be called once in every test it() block at the 11 | * beginning. Otherwise, subsequent calls might not yield the expected results. 12 | * 13 | * @param callback The callback to invoke when output is emitted. 14 | * @param shouldLog Whether to log the terminal output to the console. 15 | * @param withoutAnsi Whether to clean the output from ANSI control codes. 16 | */ 17 | export function onAnyTerminalOutput( 18 | callback: (_data: string, _stopListening: CallableFunction) => void, 19 | shouldLog = true, 20 | withoutAnsi = true): void { 21 | const listener = window.onDidStartTerminalShellExecution( 22 | async (event: vscode.TerminalShellExecutionStartEvent) => { 23 | let stream = event.execution.read(); 24 | if (withoutAnsi) { 25 | stream = withoutAnsiCodes(stream); 26 | } 27 | 28 | const stopListening = () => { 29 | shouldLog = false; 30 | listener.dispose(); 31 | }; 32 | 33 | for await (const data of stream) { 34 | if (shouldLog) { 35 | console.log(`Terminal output: ${data}`); 36 | } 37 | callback(data, stopListening); 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /tests/utils/testRunner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test runner script that programmatically runs the test suite using Mocha. 3 | * Also see the Mocha API: https://mochajs.org/api/mocha 4 | * 5 | * Adapted from the VSCode testing extension guide [1]. 6 | * File on Github: [2]. 7 | * 8 | * [1] https://code.visualstudio.com/api/working-with-extensions/testing-extension#advanced-setup-your-own-runner 9 | * [2] https://github.com/microsoft/vscode-extension-samples/blob/main/helloworld-test-sample/src/test/suite/index.ts 10 | */ 11 | 12 | // import as soon as possible 13 | import { activatedEmitter } from "../../src/extension"; 14 | 15 | import * as assert from "assert"; 16 | import { globSync } from "glob"; 17 | import Mocha from "mocha"; 18 | import * as path from "path"; 19 | import "source-map-support/register"; 20 | import "./prototype"; 21 | 22 | import { Uri, extensions, window, workspace } from "vscode"; 23 | 24 | const WORKSPACE_ROOT: string = workspace.workspaceFolders![0].uri.fsPath; 25 | 26 | /** 27 | * Returns a Uri object for a file path relative to the workspace root. 28 | */ 29 | export function uriInWorkspace(pathRelativeToWorkspaceRoot: string): Uri { 30 | const fullPath = path.join(WORKSPACE_ROOT, pathRelativeToWorkspaceRoot); 31 | return Uri.file(fullPath); 32 | } 33 | 34 | /** 35 | * Runs the test suite. 36 | * 37 | * Note that this function is called from the launch.json test configuration 38 | * as well as when you execute "npm test" manually (in the latter case, from 39 | * the main() function in main.ts). 40 | */ 41 | export function run(): Promise { 42 | const mocha = new Mocha({ 43 | ui: "tdd", 44 | timeout: 45000, 45 | color: true, 46 | }); 47 | 48 | console.log(`💠 workspaceRoot: ${WORKSPACE_ROOT}`); 49 | assert.ok(WORKSPACE_ROOT.endsWith("fixtures")); 50 | const files: string[] = globSync("**", { cwd: WORKSPACE_ROOT }); 51 | console.log(`💠 files in root: ${files}`); 52 | 53 | return new Promise(async (resolve, reject) => { 54 | try { 55 | const testsRoot = path.resolve(__dirname, ".."); 56 | console.log(`💠 testsRoot: ${testsRoot}`); 57 | 58 | const files: string[] = globSync("**/**.test.js", 59 | { cwd: testsRoot, ignore: ["**/node_modules/**"] }); 60 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 61 | 62 | if (process.env.IS_CALLED_IN_NPM_SCRIPT !== "true") { 63 | console.log("💠 Tests requested via debug configuration"); 64 | // respective env variables are set in launch.json 65 | } else { 66 | process.env.IS_TESTING = "true"; 67 | process.env.TEST_BASE_PATH = process.env.EXTENSION_DEV_PATH; 68 | console.log("💠 Tests requested via npm script"); 69 | } 70 | 71 | // open any python file to trigger extension activation 72 | await window.showTextDocument(uriInWorkspace("basic.py")); 73 | 74 | const extension = await extensions.getExtension("manim-notebook.manim-notebook"); 75 | if (extension?.isActive) { 76 | console.log("💠 Extension is detected as *activated*"); 77 | } else { 78 | console.log("💠 Waiting for extension activation..."); 79 | await waitUntilExtensionActivated(); 80 | console.log("💠 Extension activation detected in tests"); 81 | } 82 | 83 | console.log("Running tests..."); 84 | mocha.run((failures: any) => { 85 | if (failures > 0) { 86 | reject(new Error(`${failures} tests failed.`)); 87 | } else { 88 | resolve(); 89 | } 90 | }); 91 | } catch (err) { 92 | console.error(err); 93 | reject(err); 94 | } 95 | }); 96 | } 97 | 98 | /** 99 | * Waits until the Manim Notebook extension is activated. 100 | */ 101 | async function waitUntilExtensionActivated(): Promise { 102 | await new Promise((resolve, reject) => { 103 | activatedEmitter.on("activated", () => resolve()); 104 | setTimeout(() => { 105 | reject(new Error("Extension activation timeout")); 106 | }, 20000); 107 | }); 108 | } 109 | --------------------------------------------------------------------------------