From 534de31ad8111d88654d014f1cce2803c7465b65 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 10:39:04 +0200 Subject: [PATCH 01/58] Init new ManimShell wrapper --- src/extension.ts | 3 +++ src/manimShell.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/manimShell.ts diff --git a/src/extension.ts b/src/extension.ts index c0448575..3dbd9a47 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { ManimShell } from './manimShell'; import { ManimCell } from './manimCell'; import { ManimCellRanges } from './manimCellRanges'; import { previewCode } from './previewCode'; @@ -43,6 +44,8 @@ export function activate(context: vscode.ExtensionContext) { clearSceneCommand ); registerManimCellProviders(context); + + const shell = new ManimShell(); } export function deactivate() { } diff --git a/src/manimShell.ts b/src/manimShell.ts new file mode 100644 index 00000000..9d270e03 --- /dev/null +++ b/src/manimShell.ts @@ -0,0 +1,46 @@ +import * as vscode from 'vscode'; +import { window } from 'vscode'; + +// https://stackoverflow.com/a/14693789/ +const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; +const MANIM_WELCOME_STRING = "ManimGL"; + +export class ManimShell { + // At the moment assume that there is only one shell that executes ManimGL + private activeShell: vscode.Terminal | null = null; + + constructor() { + this.initiateActiveShellSearching(); + } + + /** + * Continuously searches for the active shell that executes ManimGL. + */ + private initiateActiveShellSearching() { + window.onDidStartTerminalShellExecution( + async (event: vscode.TerminalShellExecutionStartEvent) => { + const stream = event.execution.read(); + for await (const data of withoutAnsiCodes(stream)) { + console.log(`🎧: ${data}`); + if (data.includes(MANIM_WELCOME_STRING)) { + this.activeShell = event.terminal; + } + } + }); + + window.onDidEndTerminalShellExecution(async (event: vscode.TerminalShellExecutionEndEvent) => { + console.log('🔎 onDidEndTerminalShellExecution', event); + }); + } + +} + +async function* withoutAnsiCodes(stream: AsyncIterable) { + for await (const data of stream) { + yield stripAnsiCodes(data); + } +} + +function stripAnsiCodes(str: string) { + return str.replace(ANSI_CONTROL_SEQUENCE_REGEX, ''); +} From 7c86072d6d697588431bf3a46f6f24b2b0865f8a Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 11:44:54 +0200 Subject: [PATCH 02/58] Include ManimShell as additional layer in other commands --- src/executeTerminalCommand.ts | 19 ------ src/extension.ts | 118 +++++----------------------------- src/manimCell.ts | 2 +- src/manimShell.ts | 53 ++++++++++++++- src/previewCode.ts | 6 +- src/startScene.ts | 100 ++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 127 deletions(-) delete mode 100644 src/executeTerminalCommand.ts create mode 100644 src/startScene.ts diff --git a/src/executeTerminalCommand.ts b/src/executeTerminalCommand.ts deleted file mode 100644 index e3cbe3cb..00000000 --- a/src/executeTerminalCommand.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as vscode from 'vscode'; - -/** - * Executes the given command in the VSCode terminal: - * - either using shell integration (if supported), - * - otherwise using `sendText`. - * - * @param command The command to execute in the VSCode terminal. - */ -export function executeTerminalCommand(command: string): void { - // See the new Terminal shell integration API (from VSCode release 1.93) - // https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api - const terminal = vscode.window.activeTerminal || vscode.window.createTerminal(); - if (terminal.shellIntegration) { - terminal.shellIntegration.executeCommand(command); - } else { - terminal.sendText(command); - } -} diff --git a/src/extension.ts b/src/extension.ts index 3dbd9a47..f1eb0f1c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,13 +3,13 @@ import { ManimShell } from './manimShell'; import { ManimCell } from './manimCell'; import { ManimCellRanges } from './manimCellRanges'; import { previewCode } from './previewCode'; -import { executeTerminalCommand } from './executeTerminalCommand'; +import { startScene } from './startScene'; export function activate(context: vscode.ExtensionContext) { const previewManimCellCommand = vscode.commands.registerCommand( - 'manim-notebook.previewManimCell', (cellCode: string | undefined) => { - previewManimCell(cellCode); + 'manim-notebook.previewManimCell', (cellCode?: string, startLine?: number) => { + previewManimCell(cellCode, startLine); }); const previewSelectionCommand = vscode.commands.registerCommand( @@ -44,8 +44,6 @@ export function activate(context: vscode.ExtensionContext) { clearSceneCommand ); registerManimCellProviders(context); - - const shell = new ManimShell(); } export function deactivate() { } @@ -55,7 +53,9 @@ export function deactivate() { } * (when accessed via the command pallette) or the code of the cell where * the codelens was clicked. */ -function previewManimCell(cellCode: string | undefined) { +function previewManimCell(cellCode?: string, startLine?: number) { + let line = startLine; + // User has executed the command via command pallette if (cellCode === undefined) { const editor = vscode.window.activeTextEditor; @@ -68,6 +68,7 @@ function previewManimCell(cellCode: string | undefined) { // Get the code of the cell where the cursor is placed const cursorLine = editor.selection.active.line; + line = cursorLine; const range = ManimCellRanges.getCellRangeAtLine(document, cursorLine); if (!range) { vscode.window.showErrorMessage('Place your cursor in a Manim cell.'); @@ -76,7 +77,12 @@ function previewManimCell(cellCode: string | undefined) { cellCode = document.getText(range); } - previewCode(cellCode); + if (line === undefined) { + vscode.window.showErrorMessage('Internal error: Line number not found. Please report this bug.'); + return; + } + + previewCode(cellCode, line); } /** @@ -109,99 +115,7 @@ function previewSelection() { return; } - previewCode(selectedText); -} - -/** - * Runs the `manimgl` command in the terminal, with the current cursor's line number: - * manimgl [-se ] - * - * - Saves the active file. - * - Previews the scene at the cursor's line (end of line) - * - If the cursor is on a class definition line, then `-se ` - * is NOT added, i.e. the whole scene is previewed. - * - (3b1b's version also copies this command to the clipboard with additional - * args `--prerun --finder -w`. We don't do that here.) - */ -async function startScene() { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showErrorMessage( - 'No opened file found. Please place your cursor at a line of code.' - ); - return; - } - - // Save active file - vscode.commands.executeCommand('workbench.action.files.save'); - - const languageId = editor.document.languageId; - if (languageId !== 'python') { - vscode.window.showErrorMessage("You don't have a Python file open."); - return; - } - - const lines = editor.document.getText().split("\n"); - - // Find which lines define classes - // E.g. here, classLines = [{ line: "class FirstScene(Scene):", index: 3 }, ...] - const classLines = lines - .map((line, index) => ({ line, index })) - .filter(({ line }) => /^class (.+?)\((.+?)\):/.test(line)); - - const cursorLine = editor.selection.start.line; - - // Find the first class defined before where the cursor is - // E.g. here, matchingClass = { line: "class SelectedScene(Scene):", index: 42 } - const matchingClass = classLines - .reverse() - .find(({ index }) => index <= cursorLine); - if (!matchingClass) { - vscode.window.showErrorMessage('Place your cursor in Manim code inside a class.'); - return; - } - // E.g. here, sceneName = "SelectedScene" - const sceneName = matchingClass.line.slice("class ".length, matchingClass.line.indexOf("(")); - - // While line is empty - make it the previous line - // (because `manimgl -se ` doesn't work on empty lines) - let lineNumber = cursorLine; - while (lines[lineNumber].trim() === "") { - lineNumber--; - } - - // Create the command - const filePath = editor.document.fileName; // absolute path - const cmds = ["manimgl", filePath, sceneName]; - let enter = false; - if (cursorLine !== matchingClass.index) { - cmds.push(`-se ${lineNumber + 1}`); - enter = true; - } - const command = cmds.join(" "); - - // // Commented out - in case someone would like it. - // // For us - we want to NOT overwrite our clipboard. - // // If one wants to run it in a different terminal, - // // it's often to write to a file - // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); - - // Run the command - executeTerminalCommand(command); - - // // Commented out - in case someone would like it. - // // For us - it would require MacOS. Also - the effect is not desired. - // // Focus some windows (ONLY for MacOS because it uses `osascript`!) - // const terminal = vscode.window.activeTerminal || vscode.window.createTerminal(); - // if (enter) { - // // Keep cursor where it started (in VSCode) - // const cmd_focus_vscode = 'osascript -e "tell application \\"Visual Studio Code\\" to activate"'; - // // Execute the command in the shell after a delay (to give the animation window enough time to open) - // await new Promise(resolve => setTimeout(resolve, 2500)); - // require('child_process').exec(cmd_focus_vscode); - // } else { - // terminal.show(); - // } + previewCode(selectedText, selection.start.line); } /** @@ -209,7 +123,7 @@ async function startScene() { * and the IPython terminal. */ function exitScene() { - executeTerminalCommand("exit()"); + ManimShell.instance.executeCommand("exit()"); } /** @@ -217,7 +131,7 @@ function exitScene() { * the scene. */ function clearScene() { - executeTerminalCommand("clear()"); + ManimShell.instance.executeCommand("clear()"); } /** diff --git a/src/manimCell.ts b/src/manimCell.ts index a397a222..a24174e0 100644 --- a/src/manimCell.ts +++ b/src/manimCell.ts @@ -50,7 +50,7 @@ export class ManimCell implements vscode.CodeLensProvider, vscode.FoldingRangePr title: "▶ Preview Manim Cell", command: "manim-notebook.previewManimCell", tooltip: "Preview this Manim Cell inside an interactive Manim environment", - arguments: [cellCode] + arguments: [cellCode, codeLens.range.start.line] }; return codeLens; diff --git a/src/manimShell.ts b/src/manimShell.ts index 9d270e03..7632a9ee 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -1,18 +1,68 @@ import * as vscode from 'vscode'; import { window } from 'vscode'; +import { startScene } from './startScene'; // https://stackoverflow.com/a/14693789/ const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; const MANIM_WELCOME_STRING = "ManimGL"; +/** + * TODO Description. + * + * Note that shell is used as synonym for "terminal" here. + */ export class ManimShell { + // for use with singleton pattern + static #instance: ManimShell; + // At the moment assume that there is only one shell that executes ManimGL private activeShell: vscode.Terminal | null = null; - constructor() { + + private constructor() { this.initiateActiveShellSearching(); } + public static get instance(): ManimShell { + if (!ManimShell.#instance) { + ManimShell.#instance = new ManimShell(); + } + return ManimShell.#instance; + } + + /** + * Executes the given command in the VSCode terminal: + * - either using shell integration (if supported), + * - otherwise using `sendText`. + * + * If no active terminal running Manim is found, a new terminal is created. + * + * @param command The command to execute in the VSCode terminal. + */ + public async executeCommand(command: string, startLine?: number): Promise { + const shell = await this.retrieveActiveShell(startLine); + + // See the new Terminal shell integration API (from VSCode release 1.93) + // https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api + if (shell.shellIntegration) { + shell.shellIntegration.executeCommand(command); + } else { + shell.sendText(command); + } + } + + private async retrieveActiveShell(startLine?: number): Promise { + if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { + console.log("❌ Active shell is null"); + this.activeShell = vscode.window.createTerminal(); + startScene(startLine); + await new Promise(resolve => setTimeout(resolve, 5000)); + + } + console.log(`✅ Active shell: ${this.activeShell}`); + return this.activeShell; + } + /** * Continuously searches for the active shell that executes ManimGL. */ @@ -32,7 +82,6 @@ export class ManimShell { console.log('🔎 onDidEndTerminalShellExecution', event); }); } - } async function* withoutAnsiCodes(stream: AsyncIterable) { diff --git a/src/previewCode.ts b/src/previewCode.ts index a7c3e214..6bf313d9 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; +import { ManimShell } from './manimShell'; import { window } from 'vscode'; -import { executeTerminalCommand } from './executeTerminalCommand'; const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; // \x0C: is Ctrl + L @@ -36,7 +36,7 @@ let isExecuting = false; * * @param code The code to preview (e.g. from a Manim cell or from a custom selection). */ -export async function previewCode(code: string): Promise { +export async function previewCode(code: string, startLine: number): Promise { if (isExecuting) { vscode.window.showInformationMessage('Please wait a few seconds, then try again.'); return; @@ -47,7 +47,7 @@ export async function previewCode(code: string): Promise { const clipboardBuffer = await vscode.env.clipboard.readText(); await vscode.env.clipboard.writeText(code); - executeTerminalCommand(PREVIEW_COMMAND); + ManimShell.instance.executeCommand(PREVIEW_COMMAND, startLine); // Restore original clipboard content const timeout = vscode.workspace.getConfiguration("manim-notebook").clipboardTimeout; diff --git a/src/startScene.ts b/src/startScene.ts new file mode 100644 index 00000000..81b1c0b3 --- /dev/null +++ b/src/startScene.ts @@ -0,0 +1,100 @@ +import * as vscode from 'vscode'; +import { ManimShell } from './manimShell'; +import { window } from 'vscode'; + +/** + * Runs the `manimgl` command in the terminal, with the current cursor's line number: + * manimgl [-se ] + * + * - Saves the active file. + * - Previews the scene at the cursor's line (end of line) + * - If the cursor is on a class definition line, then `-se ` + * is NOT added, i.e. the whole scene is previewed. + * - (3b1b's version also copies this command to the clipboard with additional + * args `--prerun --finder -w`. We don't do that here.) + */ +export async function startScene(line?: number) { + const editor = window.activeTextEditor; + if (!editor) { + window.showErrorMessage( + 'No opened file found. Please place your cursor at a line of code.' + ); + return; + } + + // Save active file + vscode.commands.executeCommand('workbench.action.files.save'); + + const languageId = editor.document.languageId; + if (languageId !== 'python') { + window.showErrorMessage("You don't have a Python file open."); + return; + } + + const lines = editor.document.getText().split("\n"); + + // Find which lines define classes + // E.g. here, classLines = [{ line: "class FirstScene(Scene):", index: 3 }, ...] + const classLines = lines + .map((line, index) => ({ line, index })) + .filter(({ line }) => /^class (.+?)\((.+?)\):/.test(line)); + + let cursorLine; + if (line !== undefined) { + cursorLine = line; + } else { + cursorLine = editor.selection.start.line; + } + + // Find the first class defined before where the cursor is + // E.g. here, matchingClass = { line: "class SelectedScene(Scene):", index: 42 } + const matchingClass = classLines + .reverse() + .find(({ index }) => index <= cursorLine); + if (!matchingClass) { + window.showErrorMessage('Place your cursor in Manim code inside a class.'); + return; + } + // E.g. here, sceneName = "SelectedScene" + const sceneName = matchingClass.line.slice("class ".length, matchingClass.line.indexOf("(")); + + // While line is empty - make it the previous line + // (because `manimgl -se ` doesn't work on empty lines) + let lineNumber = cursorLine; + while (lines[lineNumber].trim() === "") { + lineNumber--; + } + + // Create the command + const filePath = editor.document.fileName; // absolute path + const cmds = ["manimgl", filePath, sceneName]; + let enter = false; + if (cursorLine !== matchingClass.index) { + cmds.push(`-se ${lineNumber + 1}`); + enter = true; + } + const command = cmds.join(" "); + + // // Commented out - in case someone would like it. + // // For us - we want to NOT overwrite our clipboard. + // // If one wants to run it in a different terminal, + // // it's often to write to a file + // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); + + // Run the command + ManimShell.instance.executeCommand(command); + + // // Commented out - in case someone would like it. + // // For us - it would require MacOS. Also - the effect is not desired. + // // Focus some windows (ONLY for MacOS because it uses `osascript`!) + // const terminal = window.activeTerminal || window.createTerminal(); + // if (enter) { + // // Keep cursor where it started (in VSCode) + // const cmd_focus_vscode = 'osascript -e "tell application \\"Visual Studio Code\\" to activate"'; + // // Execute the command in the shell after a delay (to give the animation window enough time to open) + // await new Promise(resolve => setTimeout(resolve, 2500)); + // require('child_process').exec(cmd_focus_vscode); + // } else { + // terminal.show(); + // } +} From 7436cb0c0ef4e3519d50312be3b1ef9db025f429 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 13:03:31 +0200 Subject: [PATCH 03/58] Wait until command in IPython Terminal is finished --- src/extension.ts | 1 + src/manimShell.ts | 56 +++++++++++++++++++++++++++++++++++------------ src/startScene.ts | 2 +- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index f1eb0f1c..2543da14 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -124,6 +124,7 @@ function previewSelection() { */ function exitScene() { ManimShell.instance.executeCommand("exit()"); + ManimShell.instance.resetActiveShell(); } /** diff --git a/src/manimShell.ts b/src/manimShell.ts index 7632a9ee..5778ba3c 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -1,11 +1,17 @@ import * as vscode from 'vscode'; import { window } from 'vscode'; import { startScene } from './startScene'; +import { EventEmitter } from 'events'; // https://stackoverflow.com/a/14693789/ const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; +const IPYTHON_CELL_START_REGEX = /^\s*In \[\d+\]:/m; const MANIM_WELCOME_STRING = "ManimGL"; +enum ManimShellEvent { + IPYTHON_CELL_FINISHED = 'ipythonCellFinished', +} + /** * TODO Description. * @@ -17,10 +23,11 @@ export class ManimShell { // At the moment assume that there is only one shell that executes ManimGL private activeShell: vscode.Terminal | null = null; + private eventEmitter = new EventEmitter(); private constructor() { - this.initiateActiveShellSearching(); + this.initiateTerminalDataReading(); } public static get instance(): ManimShell { @@ -30,6 +37,10 @@ export class ManimShell { return ManimShell.#instance; } + public resetActiveShell() { + this.activeShell = null; + } + /** * Executes the given command in the VSCode terminal: * - either using shell integration (if supported), @@ -37,49 +48,66 @@ export class ManimShell { * * If no active terminal running Manim is found, a new terminal is created. * + * The actual command execution is not awaited for. + * * @param command The command to execute in the VSCode terminal. */ - public async executeCommand(command: string, startLine?: number): Promise { - const shell = await this.retrieveActiveShell(startLine); - - // See the new Terminal shell integration API (from VSCode release 1.93) - // https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api + public async executeCommand(command: string, startLine?: number) { + console.log(`🙌 Executing command: ${command}, startLine: ${startLine}`); + const clipboardBuffer = await vscode.env.clipboard.readText(); + const shell = await this.retrieveOrInitActiveShell(startLine); if (shell.shellIntegration) { shell.shellIntegration.executeCommand(command); } else { shell.sendText(command); } + await vscode.env.clipboard.writeText(clipboardBuffer); + } + + /** + * Executes the given command and waits for the IPython cell to finish. + * + * @param command The command to execute in the VSCode terminal. + */ + public async executeCommandAndWait(command: string) { + this.executeCommand(command); + await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); } - private async retrieveActiveShell(startLine?: number): Promise { + private async retrieveOrInitActiveShell(startLine?: number): Promise { if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { - console.log("❌ Active shell is null"); this.activeShell = vscode.window.createTerminal(); - startScene(startLine); - await new Promise(resolve => setTimeout(resolve, 5000)); - + await startScene(startLine); } - console.log(`✅ Active shell: ${this.activeShell}`); return this.activeShell; } /** * Continuously searches for the active shell that executes ManimGL. */ - private initiateActiveShellSearching() { + private initiateTerminalDataReading() { window.onDidStartTerminalShellExecution( async (event: vscode.TerminalShellExecutionStartEvent) => { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { console.log(`🎧: ${data}`); + // TODO: detect quitting of IPython terminal using + // raw ANSI escape sequences if (data.includes(MANIM_WELCOME_STRING)) { this.activeShell = event.terminal; } + + if (data.match(IPYTHON_CELL_START_REGEX)) { + this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); + } + } }); window.onDidEndTerminalShellExecution(async (event: vscode.TerminalShellExecutionEndEvent) => { - console.log('🔎 onDidEndTerminalShellExecution', event); + console.log('▶ onDidEndTerminalShellExecution', event); }); } } diff --git a/src/startScene.ts b/src/startScene.ts index 81b1c0b3..35d52446 100644 --- a/src/startScene.ts +++ b/src/startScene.ts @@ -82,7 +82,7 @@ export async function startScene(line?: number) { // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); // Run the command - ManimShell.instance.executeCommand(command); + await ManimShell.instance.executeCommandAndWait(command); // // Commented out - in case someone would like it. // // For us - it would require MacOS. Also - the effect is not desired. From 34046ab0bedbbe5fc80dff5fd0953aad50dcad01 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 14:30:48 +0200 Subject: [PATCH 04/58] Detect shell exit --- src/manimShell.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 5778ba3c..b34b49f4 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -24,6 +24,7 @@ export class ManimShell { // At the moment assume that there is only one shell that executes ManimGL private activeShell: vscode.Terminal | null = null; private eventEmitter = new EventEmitter(); + private detectShellExit = true; private constructor() { @@ -55,12 +56,17 @@ export class ManimShell { public async executeCommand(command: string, startLine?: number) { console.log(`🙌 Executing command: ${command}, startLine: ${startLine}`); const clipboardBuffer = await vscode.env.clipboard.readText(); + const shell = await this.retrieveOrInitActiveShell(startLine); + + this.detectShellExit = false; if (shell.shellIntegration) { shell.shellIntegration.executeCommand(command); } else { shell.sendText(command); } + this.detectShellExit = true; + await vscode.env.clipboard.writeText(clipboardBuffer); } @@ -93,31 +99,29 @@ export class ManimShell { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { console.log(`🎧: ${data}`); - // TODO: detect quitting of IPython terminal using - // raw ANSI escape sequences if (data.includes(MANIM_WELCOME_STRING)) { this.activeShell = event.terminal; } - if (data.match(IPYTHON_CELL_START_REGEX)) { this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); } - } }); - window.onDidEndTerminalShellExecution(async (event: vscode.TerminalShellExecutionEndEvent) => { - console.log('▶ onDidEndTerminalShellExecution', event); - }); + window.onDidEndTerminalShellExecution( + async (event: vscode.TerminalShellExecutionEndEvent) => { + if (!this.detectShellExit) { + return; + } + if (event.terminal === this.activeShell) { + this.resetActiveShell(); + } + }); } } async function* withoutAnsiCodes(stream: AsyncIterable) { for await (const data of stream) { - yield stripAnsiCodes(data); + yield data.replace(ANSI_CONTROL_SEQUENCE_REGEX, ''); } } - -function stripAnsiCodes(str: string) { - return str.replace(ANSI_CONTROL_SEQUENCE_REGEX, ''); -} From 64112416b300a3773002ab1790ee47882a21b17f Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 14:51:45 +0200 Subject: [PATCH 05/58] Open terminal upon error --- src/manimShell.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index b34b49f4..77fd84cb 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -6,6 +6,7 @@ import { EventEmitter } from 'events'; // https://stackoverflow.com/a/14693789/ const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; const IPYTHON_CELL_START_REGEX = /^\s*In \[\d+\]:/m; +const ERROR_REGEX = /^\s*Cell In\[\d+\],\s*line\s*\d+/m; const MANIM_WELCOME_STRING = "ManimGL"; enum ManimShellEvent { @@ -105,6 +106,9 @@ export class ManimShell { if (data.match(IPYTHON_CELL_START_REGEX)) { this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); } + if (data.match(ERROR_REGEX)) { + this.activeShell?.show(); + } } }); From 5511f5dfc7f1dd7dca3999e6e040cd8af6c441ce Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:24:22 +0200 Subject: [PATCH 06/58] Improve docstrings & implement executeCommandEnsureActiveSession --- src/extension.ts | 36 ++++++++++++++++++------------- src/manimShell.ts | 54 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2543da14..9981ca91 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { window } from 'vscode'; import { ManimShell } from './manimShell'; import { ManimCell } from './manimCell'; import { ManimCellRanges } from './manimCellRanges'; @@ -58,9 +59,9 @@ function previewManimCell(cellCode?: string, startLine?: number) { // User has executed the command via command pallette if (cellCode === undefined) { - const editor = vscode.window.activeTextEditor; + const editor = window.activeTextEditor; if (!editor) { - vscode.window.showErrorMessage( + window.showErrorMessage( 'No opened file found. Place your cursor in a Manim cell.'); return; } @@ -71,14 +72,14 @@ function previewManimCell(cellCode?: string, startLine?: number) { line = cursorLine; const range = ManimCellRanges.getCellRangeAtLine(document, cursorLine); if (!range) { - vscode.window.showErrorMessage('Place your cursor in a Manim cell.'); + window.showErrorMessage('Place your cursor in a Manim cell.'); return; } cellCode = document.getText(range); } if (line === undefined) { - vscode.window.showErrorMessage('Internal error: Line number not found. Please report this bug.'); + window.showErrorMessage('Internal error: Line number not found. Please report this bug.'); return; } @@ -89,9 +90,9 @@ function previewManimCell(cellCode?: string, startLine?: number) { * Previews the Manim code of the selected text. */ function previewSelection() { - const editor = vscode.window.activeTextEditor; + const editor = window.activeTextEditor; if (!editor) { - vscode.window.showErrorMessage('Select some code to preview.'); + window.showErrorMessage('Select some code to preview.'); return; } @@ -111,7 +112,7 @@ function previewSelection() { } if (!selectedText) { - vscode.window.showErrorMessage('Select some code to preview.'); + window.showErrorMessage('Select some code to preview.'); return; } @@ -123,8 +124,11 @@ function previewSelection() { * and the IPython terminal. */ function exitScene() { - ManimShell.instance.executeCommand("exit()"); - ManimShell.instance.resetActiveShell(); + ManimShell.instance.executeCommandEnsureActiveSession("exit()", () => { + window.showErrorMessage('No active ManimGL scene found to exit.'); + }).then(() => { + ManimShell.instance.resetActiveShell(); + }); } /** @@ -132,7 +136,9 @@ function exitScene() { * the scene. */ function clearScene() { - ManimShell.instance.executeCommand("clear()"); + ManimShell.instance.executeCommandEnsureActiveSession("clear()", () => { + window.showErrorMessage('No active ManimGL scene found to remove objects from.'); + }); } /** @@ -147,24 +153,24 @@ function registerManimCellProviders(context: vscode.ExtensionContext) { { language: 'python' }, manimCell); context.subscriptions.push(codeLensProvider, foldingRangeProvider); - vscode.window.onDidChangeActiveTextEditor(editor => { + window.onDidChangeActiveTextEditor(editor => { if (editor) { manimCell.applyCellDecorations(editor); } }, null, context.subscriptions); vscode.workspace.onDidChangeTextDocument(event => { - const editor = vscode.window.activeTextEditor; + const editor = window.activeTextEditor; if (editor && event.document === editor.document) { manimCell.applyCellDecorations(editor); } }, null, context.subscriptions); - vscode.window.onDidChangeTextEditorSelection(event => { + window.onDidChangeTextEditorSelection(event => { manimCell.applyCellDecorations(event.textEditor); }, null, context.subscriptions); - if (vscode.window.activeTextEditor) { - manimCell.applyCellDecorations(vscode.window.activeTextEditor); + if (window.activeTextEditor) { + manimCell.applyCellDecorations(window.activeTextEditor); } } diff --git a/src/manimShell.ts b/src/manimShell.ts index 77fd84cb..7aea216a 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -14,20 +14,20 @@ enum ManimShellEvent { } /** - * TODO Description. + * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands + * are executed at the right place and spans a new Manim session if necessary. * - * Note that shell is used as synonym for "terminal" here. + * The words "shell" and "terminal" are used interchangeably. + * + * This class is a singleton and should be accessed via `ManimShell.instance`. */ export class ManimShell { - // for use with singleton pattern static #instance: ManimShell; - // At the moment assume that there is only one shell that executes ManimGL private activeShell: vscode.Terminal | null = null; private eventEmitter = new EventEmitter(); private detectShellExit = true; - private constructor() { this.initiateTerminalDataReading(); } @@ -39,6 +39,10 @@ export class ManimShell { return ManimShell.#instance; } + /** + * Resets the active shell such that a new terminal is created on the next + * command execution. + */ public resetActiveShell() { this.activeShell = null; } @@ -48,18 +52,32 @@ export class ManimShell { * - either using shell integration (if supported), * - otherwise using `sendText`. * - * If no active terminal running Manim is found, a new terminal is created. + * If no active terminal running Manim is found, a new terminal is spawned, + * and a new Manim session is started in it. * - * The actual command execution is not awaited for. + * Even though this method is asynchronous, it does only wait for the initial + * setup of the terminal and the creation of the Manim session, but NOT for + * the end of the actual command execution. That is, it will return before + * the actual command has finished executing. * * @param command The command to execute in the VSCode terminal. + * @param startLine The line number in the active editor where the Manim + * session should start in case a new terminal is spawned. See `startScene` + * for */ public async executeCommand(command: string, startLine?: number) { console.log(`🙌 Executing command: ${command}, startLine: ${startLine}`); const clipboardBuffer = await vscode.env.clipboard.readText(); - const shell = await this.retrieveOrInitActiveShell(startLine); + this.exec(shell, command); + await vscode.env.clipboard.writeText(clipboardBuffer); + } + /** + * Note by design not async. TODO. + * @param command + */ + private exec(shell: vscode.Terminal, command: string) { this.detectShellExit = false; if (shell.shellIntegration) { shell.shellIntegration.executeCommand(command); @@ -67,8 +85,26 @@ export class ManimShell { shell.sendText(command); } this.detectShellExit = true; + } - await vscode.env.clipboard.writeText(clipboardBuffer); + /** + * Executes the given command, but only if an active ManimGL shell exists. + * If not, the given callback is executed. + * + * @param command The command to execute in the VSCode terminal. + * @param onNoActiveSession Callback to execute if no active ManimGL shell + * exists. + * @returns A promise that resolves when the command could be successfully + * started. Note that this is NOT the point when the command execution finishes. + */ + public async executeCommandEnsureActiveSession( + command: string, onNoActiveSession: () => void): Promise { + if (this.activeShell === null) { + onNoActiveSession(); + return Promise.reject(); + } + this.exec(this.activeShell, command); + return Promise.resolve(); } /** From 1a068082690f4ffc1dd30067bb77b1eeaa3337fe Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:32:37 +0200 Subject: [PATCH 07/58] Add docstrings for `exec` method --- src/manimShell.ts | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 7aea216a..6ab4f912 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -26,7 +26,7 @@ export class ManimShell { private activeShell: vscode.Terminal | null = null; private eventEmitter = new EventEmitter(); - private detectShellExit = true; + private detectShellExecutionEnd = true; private constructor() { this.initiateTerminalDataReading(); @@ -73,20 +73,6 @@ export class ManimShell { await vscode.env.clipboard.writeText(clipboardBuffer); } - /** - * Note by design not async. TODO. - * @param command - */ - private exec(shell: vscode.Terminal, command: string) { - this.detectShellExit = false; - if (shell.shellIntegration) { - shell.shellIntegration.executeCommand(command); - } else { - shell.sendText(command); - } - this.detectShellExit = true; - } - /** * Executes the given command, but only if an active ManimGL shell exists. * If not, the given callback is executed. @@ -119,6 +105,33 @@ export class ManimShell { }); } + /** + * Executes the command in the shell using shell integration if available, + * otherwise using `sendText`. + * + * This method is NOT asynchronous by design, as Manim commands run in the + * IPython terminal that the VSCode API does not natively support. I.e. + * the event does not actually end, when the command has finished running, + * since we are still in the IPython environment. + * + * Note that this does not hold true for the initial setup of the terminal + * and the first `checkpoint_paste()` call, which is why we disable the + * detection of the "shell execution end" when while the commands are + * issued. + * + * @param shell The shell to execute the command in. + * @param command The command to execute in the shell. + */ + private exec(shell: vscode.Terminal, command: string) { + this.detectShellExecutionEnd = false; + if (shell.shellIntegration) { + shell.shellIntegration.executeCommand(command); + } else { + shell.sendText(command); + } + this.detectShellExecutionEnd = true; + } + private async retrieveOrInitActiveShell(startLine?: number): Promise { if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { this.activeShell = vscode.window.createTerminal(); @@ -150,7 +163,7 @@ export class ManimShell { window.onDidEndTerminalShellExecution( async (event: vscode.TerminalShellExecutionEndEvent) => { - if (!this.detectShellExit) { + if (!this.detectShellExecutionEnd) { return; } if (event.terminal === this.activeShell) { From 1ed95466e504f744d0d700af50a33a74187e3f5c Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:36:50 +0200 Subject: [PATCH 08/58] Add docstrings to `retrieveOrInitActiveShell` --- src/manimShell.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index 6ab4f912..cdb10d4a 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -132,6 +132,18 @@ export class ManimShell { this.detectShellExecutionEnd = true; } + /** + * Retrieves the active shell or spawns a new one if no active shell can + * be found. If a new shell is spawned, the Manim session is started at the + * given line. + * + * A shell that was previously used to run Manim, but has exited from the + * Manim session (IPython environment), is considered inactive. + * + * @param startLine The line number in the active editor where the Manim + * session should start in case a new terminal is spawned. + * Also see: `startScene()`. + */ private async retrieveOrInitActiveShell(startLine?: number): Promise { if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { this.activeShell = vscode.window.createTerminal(); From 70aabd506919f9cee2a8b9cd803b5b1c85cb44cb Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:40:49 +0200 Subject: [PATCH 09/58] Add docstrings for `initiateTerminalDataReading` --- src/manimShell.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index cdb10d4a..ff7c2701 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -153,14 +153,22 @@ export class ManimShell { } /** - * Continuously searches for the active shell that executes ManimGL. + * Inits the reading of data from the terminal and issues actions/events + * based on the data received: + * + * - If the Manim welcome string is detected, the terminal is marked as + * active. + * - If an IPython cell has finished executing, an event is emitted such + * that commands know when they are actually completely finished, e.g. + * when the whole animation has been previewed. + * - If an error is detected, the terminal is opened to show the error. + * - If the whole Manim session has ended, the active shell is reset. */ private initiateTerminalDataReading() { window.onDidStartTerminalShellExecution( async (event: vscode.TerminalShellExecutionStartEvent) => { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { - console.log(`🎧: ${data}`); if (data.includes(MANIM_WELCOME_STRING)) { this.activeShell = event.terminal; } From ab096bd69a0043e9b4c4d37165704d0c7f9988a3 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:43:34 +0200 Subject: [PATCH 10/58] Add docstrings for ANSI filtering --- src/manimShell.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index ff7c2701..f5d0d7b2 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -3,8 +3,13 @@ import { window } from 'vscode'; import { startScene } from './startScene'; import { EventEmitter } from 'events'; -// https://stackoverflow.com/a/14693789/ +/** + * Regular expression to match ANSI control sequences. Even though we might miss + * some control sequences, this is good enough for our purposes as the relevant + * ones are matched. From: https://stackoverflow.com/a/14693789/ + */ const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; + const IPYTHON_CELL_START_REGEX = /^\s*In \[\d+\]:/m; const ERROR_REGEX = /^\s*Cell In\[\d+\],\s*line\s*\d+/m; const MANIM_WELCOME_STRING = "ManimGL"; @@ -193,6 +198,13 @@ export class ManimShell { } } +/** + * Removes ANSI control codes from the given stream of strings and yields the + * cleaned strings. + * + * @param stream The stream of strings to clean. + * @returns An async iterable stream of strings without ANSI control codes. + */ async function* withoutAnsiCodes(stream: AsyncIterable) { for await (const data of stream) { yield data.replace(ANSI_CONTROL_SEQUENCE_REGEX, ''); From 8f8d254f6c9d3e5f498b51b5775925cd6468bc4e Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:48:59 +0200 Subject: [PATCH 11/58] Add docstrings to regular expressions --- src/manimShell.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index f5d0d7b2..cf039957 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -10,11 +10,29 @@ import { EventEmitter } from 'events'; */ const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])/g; +/** + * Regular expression to match the start of an IPython cell, e.g. "In [5]:" + */ const IPYTHON_CELL_START_REGEX = /^\s*In \[\d+\]:/m; + +/** + * Regular expression to match an error message in the terminal. For any error + * message, IPython prints "Cell In[], line " to a new line. + */ const ERROR_REGEX = /^\s*Cell In\[\d+\],\s*line\s*\d+/m; -const MANIM_WELCOME_STRING = "ManimGL"; + +/** + * Regular expression to match the welcome string that ManimGL prints when the + * interactive session is started, e.g. "ManimGL v1.7.1". This is used to detect + * if the terminal is related to ManimGL. + */ +const MANIM_WELCOME_REGEX = /^\s*ManimGL/m; enum ManimShellEvent { + /** + * Event emitted when an IPython cell has finished executing, i.e. when the + * IPYTHON_CELL_START_REGEX is matched. + */ IPYTHON_CELL_FINISHED = 'ipythonCellFinished', } From d426004eb9f77267b80325ff47ebaf1300719acd Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 15:49:43 +0200 Subject: [PATCH 12/58] Remove log line --- src/manimShell.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index cf039957..382df952 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -89,7 +89,6 @@ export class ManimShell { * for */ public async executeCommand(command: string, startLine?: number) { - console.log(`🙌 Executing command: ${command}, startLine: ${startLine}`); const clipboardBuffer = await vscode.env.clipboard.readText(); const shell = await this.retrieveOrInitActiveShell(startLine); this.exec(shell, command); From 576d1a4e2500b68d50cfc7f82635f622bc6f64a8 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 16:18:14 +0200 Subject: [PATCH 13/58] Fix wrong variable usage --- src/manimShell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 382df952..0a015d8e 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -191,7 +191,7 @@ export class ManimShell { async (event: vscode.TerminalShellExecutionStartEvent) => { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { - if (data.includes(MANIM_WELCOME_STRING)) { + if (data.match(MANIM_WELCOME_REGEX)) { this.activeShell = event.terminal; } if (data.match(IPYTHON_CELL_START_REGEX)) { From d1f30d831efe19edbc4524e39fa354525952cdaf Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 16:20:53 +0200 Subject: [PATCH 14/58] Simplify overly complicated Promises/callbacks --- src/extension.ts | 14 ++++++++------ src/manimShell.ts | 29 +++++++++++++++-------------- src/startScene.ts | 6 +++++- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 9981ca91..c7a204bf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -124,11 +124,12 @@ function previewSelection() { * and the IPython terminal. */ function exitScene() { - ManimShell.instance.executeCommandEnsureActiveSession("exit()", () => { - window.showErrorMessage('No active ManimGL scene found to exit.'); - }).then(() => { + const success = ManimShell.instance.executeCommandEnsureActiveSession("exit()"); + if (success) { ManimShell.instance.resetActiveShell(); - }); + } else { + window.showErrorMessage('No active ManimGL scene found to exit.'); + } } /** @@ -136,9 +137,10 @@ function exitScene() { * the scene. */ function clearScene() { - ManimShell.instance.executeCommandEnsureActiveSession("clear()", () => { + const success = ManimShell.instance.executeCommandEnsureActiveSession("clear()"); + if (!success) { window.showErrorMessage('No active ManimGL scene found to remove objects from.'); - }); + } } /** diff --git a/src/manimShell.ts b/src/manimShell.ts index 0a015d8e..e4b046cb 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -97,34 +97,35 @@ export class ManimShell { /** * Executes the given command, but only if an active ManimGL shell exists. - * If not, the given callback is executed. * * @param command The command to execute in the VSCode terminal. - * @param onNoActiveSession Callback to execute if no active ManimGL shell - * exists. - * @returns A promise that resolves when the command could be successfully - * started. Note that this is NOT the point when the command execution finishes. + * @returns A boolean indicating whether an active shell was found or not. */ - public async executeCommandEnsureActiveSession( - command: string, onNoActiveSession: () => void): Promise { - if (this.activeShell === null) { - onNoActiveSession(); - return Promise.reject(); + public executeCommandEnsureActiveSession(command: string): boolean { + if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { + return false; } this.exec(this.activeShell, command); - return Promise.resolve(); + return true; } /** - * Executes the given command and waits for the IPython cell to finish. + * Executes the given command and waits for the IPython cell to finish. Also + * ensures that an active ManimGL shell exists. * * @param command The command to execute in the VSCode terminal. + * @results A boolean indicating whether an active shell was found or not. */ - public async executeCommandAndWait(command: string) { - this.executeCommand(command); + public async executeCommandEnsureActiveSessionAndWait(command: string) { + const success = this.executeCommandEnsureActiveSession(command); + if (!success) { + return false; + } + await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); + return true; } /** diff --git a/src/startScene.ts b/src/startScene.ts index 35d52446..11593b77 100644 --- a/src/startScene.ts +++ b/src/startScene.ts @@ -82,7 +82,11 @@ export async function startScene(line?: number) { // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); // Run the command - await ManimShell.instance.executeCommandAndWait(command); + const success = await ManimShell.instance.executeCommandEnsureActiveSessionAndWait(command); + if (!success) { + window.showErrorMessage( + 'Internal error: Failed to spawn a new ManimGL shell inside `startScene()`'); + } // // Commented out - in case someone would like it. // // For us - it would require MacOS. Also - the effect is not desired. From 50181e9fcf1d7ac2f83355bc48085ce22572021e Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 16:23:30 +0200 Subject: [PATCH 15/58] Explain only divergence of notation --- src/manimShell.ts | 4 ++-- src/startScene.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index e4b046cb..f21f8300 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -110,8 +110,8 @@ export class ManimShell { } /** - * Executes the given command and waits for the IPython cell to finish. Also - * ensures that an active ManimGL shell exists. + * Executes the given command and waits for the IPython cell to finish, + * but only if an active ManimGL shell exists. * * @param command The command to execute in the VSCode terminal. * @results A boolean indicating whether an active shell was found or not. diff --git a/src/startScene.ts b/src/startScene.ts index 11593b77..aae80f5a 100644 --- a/src/startScene.ts +++ b/src/startScene.ts @@ -82,6 +82,10 @@ export async function startScene(line?: number) { // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); // Run the command + // Note that this is the only point where "terminal" and "active Manim session" + // actually DON'T denote the same thing. At this point, we have a handle on + // an existing VSCode terminal, but that one is not running ManimGL yet. + // Therefore, we now spawn the interactive Manim session. const success = await ManimShell.instance.executeCommandEnsureActiveSessionAndWait(command); if (!success) { window.showErrorMessage( From 75c519f0d15a0c7fc326001fc799f2013040a604 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 16:35:47 +0200 Subject: [PATCH 16/58] Don't carry optional parameter startLine around --- src/extension.ts | 10 +++++----- src/manimShell.ts | 17 +++++++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index c7a204bf..1462d640 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -55,7 +55,7 @@ export function deactivate() { } * the codelens was clicked. */ function previewManimCell(cellCode?: string, startLine?: number) { - let line = startLine; + let startLineFinal: number | undefined = startLine; // User has executed the command via command pallette if (cellCode === undefined) { @@ -69,21 +69,21 @@ function previewManimCell(cellCode?: string, startLine?: number) { // Get the code of the cell where the cursor is placed const cursorLine = editor.selection.active.line; - line = cursorLine; const range = ManimCellRanges.getCellRangeAtLine(document, cursorLine); if (!range) { window.showErrorMessage('Place your cursor in a Manim cell.'); return; } cellCode = document.getText(range); + startLineFinal = range.start.line; } - if (line === undefined) { - window.showErrorMessage('Internal error: Line number not found. Please report this bug.'); + if (startLineFinal === undefined) { + window.showErrorMessage('Internal error: Line number not found in `previewManimCell()`.'); return; } - previewCode(cellCode, line); + previewCode(cellCode, startLineFinal); } /** diff --git a/src/manimShell.ts b/src/manimShell.ts index f21f8300..887932de 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -71,12 +71,9 @@ export class ManimShell { } /** - * Executes the given command in the VSCode terminal: - * - either using shell integration (if supported), - * - otherwise using `sendText`. - * - * If no active terminal running Manim is found, a new terminal is spawned, - * and a new Manim session is started in it. + * Executes the given command in a VSCode terminal. If no active terminal + * running Manim is found, a new terminal is spawned, and a new Manim + * session is started in it before executing the given command. * * Even though this method is asynchronous, it does only wait for the initial * setup of the terminal and the creation of the Manim session, but NOT for @@ -85,10 +82,10 @@ export class ManimShell { * * @param command The command to execute in the VSCode terminal. * @param startLine The line number in the active editor where the Manim - * session should start in case a new terminal is spawned. See `startScene` - * for + * session should start in case a new terminal is spawned. + * Also see `startScene()`. */ - public async executeCommand(command: string, startLine?: number) { + public async executeCommand(command: string, startLine: number) { const clipboardBuffer = await vscode.env.clipboard.readText(); const shell = await this.retrieveOrInitActiveShell(startLine); this.exec(shell, command); @@ -167,7 +164,7 @@ export class ManimShell { * session should start in case a new terminal is spawned. * Also see: `startScene()`. */ - private async retrieveOrInitActiveShell(startLine?: number): Promise { + private async retrieveOrInitActiveShell(startLine: number): Promise { if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { this.activeShell = vscode.window.createTerminal(); await startScene(startLine); From 7c083c407d9b0229c81a715771fcf0e6777f82a4 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 16:59:02 +0200 Subject: [PATCH 17/58] Simplify execute command waiting --- src/extension.ts | 8 ++++---- src/manimShell.ts | 46 +++++++++++++++++++--------------------------- src/startScene.ts | 18 ++++++------------ 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 1462d640..64429e52 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -123,8 +123,8 @@ function previewSelection() { * Runs the `exit()` command in the terminal to close the animation window * and the IPython terminal. */ -function exitScene() { - const success = ManimShell.instance.executeCommandEnsureActiveSession("exit()"); +async function exitScene() { + const success = await ManimShell.instance.executeCommandEnsureActiveSession("exit()"); if (success) { ManimShell.instance.resetActiveShell(); } else { @@ -136,8 +136,8 @@ function exitScene() { * Runs the `clear()` command in the terminal to remove all objects from * the scene. */ -function clearScene() { - const success = ManimShell.instance.executeCommandEnsureActiveSession("clear()"); +async function clearScene() { + const success = await ManimShell.instance.executeCommandEnsureActiveSession("clear()"); if (!success) { window.showErrorMessage('No active ManimGL scene found to remove objects from.'); } diff --git a/src/manimShell.ts b/src/manimShell.ts index 887932de..2d52f181 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -75,20 +75,22 @@ export class ManimShell { * running Manim is found, a new terminal is spawned, and a new Manim * session is started in it before executing the given command. * - * Even though this method is asynchronous, it does only wait for the initial - * setup of the terminal and the creation of the Manim session, but NOT for - * the end of the actual command execution. That is, it will return before - * the actual command has finished executing. - * * @param command The command to execute in the VSCode terminal. * @param startLine The line number in the active editor where the Manim * session should start in case a new terminal is spawned. * Also see `startScene()`. + * @param [waitUntilFinished=false] Whether to wait until the actual command + * has finished executing, e.g. when the whole animation has been previewed. */ - public async executeCommand(command: string, startLine: number) { + public async executeCommand(command: string, startLine: number, waitUntilFinished = false) { const clipboardBuffer = await vscode.env.clipboard.readText(); const shell = await this.retrieveOrInitActiveShell(startLine); this.exec(shell, command); + if (waitUntilFinished) { + await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); + } await vscode.env.clipboard.writeText(clipboardBuffer); } @@ -96,33 +98,23 @@ export class ManimShell { * Executes the given command, but only if an active ManimGL shell exists. * * @param command The command to execute in the VSCode terminal. + * @param [waitUntilFinished=false] Whether to wait until the actual command + * has finished executing, e.g. when the whole animation has been previewed. * @returns A boolean indicating whether an active shell was found or not. + * If no active shell was found, the command was also not executed. */ - public executeCommandEnsureActiveSession(command: string): boolean { + public async executeCommandEnsureActiveSession( + command: string, waitUntilFinished = false): Promise { if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { - return false; + return Promise.resolve(false); } this.exec(this.activeShell, command); - return true; - } - - /** - * Executes the given command and waits for the IPython cell to finish, - * but only if an active ManimGL shell exists. - * - * @param command The command to execute in the VSCode terminal. - * @results A boolean indicating whether an active shell was found or not. - */ - public async executeCommandEnsureActiveSessionAndWait(command: string) { - const success = this.executeCommandEnsureActiveSession(command); - if (!success) { - return false; + if (waitUntilFinished) { + await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); } - - await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); - }); - return true; + return Promise.resolve(false); } /** diff --git a/src/startScene.ts b/src/startScene.ts index aae80f5a..d155ed7f 100644 --- a/src/startScene.ts +++ b/src/startScene.ts @@ -12,8 +12,11 @@ import { window } from 'vscode'; * is NOT added, i.e. the whole scene is previewed. * - (3b1b's version also copies this command to the clipboard with additional * args `--prerun --finder -w`. We don't do that here.) + * + * @param lineStart The line number where the scene should start. If omitted, + * the scene will start from the current cursor position. */ -export async function startScene(line?: number) { +export async function startScene(lineStart?: number) { const editor = window.activeTextEditor; if (!editor) { window.showErrorMessage( @@ -39,12 +42,7 @@ export async function startScene(line?: number) { .map((line, index) => ({ line, index })) .filter(({ line }) => /^class (.+?)\((.+?)\):/.test(line)); - let cursorLine; - if (line !== undefined) { - cursorLine = line; - } else { - cursorLine = editor.selection.start.line; - } + let cursorLine = lineStart || editor.selection.start.line; // Find the first class defined before where the cursor is // E.g. here, matchingClass = { line: "class SelectedScene(Scene):", index: 42 } @@ -86,11 +84,7 @@ export async function startScene(line?: number) { // actually DON'T denote the same thing. At this point, we have a handle on // an existing VSCode terminal, but that one is not running ManimGL yet. // Therefore, we now spawn the interactive Manim session. - const success = await ManimShell.instance.executeCommandEnsureActiveSessionAndWait(command); - if (!success) { - window.showErrorMessage( - 'Internal error: Failed to spawn a new ManimGL shell inside `startScene()`'); - } + await ManimShell.instance.executeCommand(command, cursorLine, true); // // Commented out - in case someone would like it. // // For us - it would require MacOS. Also - the effect is not desired. From d75681acc4cbc3f01e041655f4dcf3f302a594dd Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 16:59:57 +0200 Subject: [PATCH 18/58] Move resetActiveShell method down --- src/manimShell.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 2d52f181..512436e0 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -62,14 +62,6 @@ export class ManimShell { return ManimShell.#instance; } - /** - * Resets the active shell such that a new terminal is created on the next - * command execution. - */ - public resetActiveShell() { - this.activeShell = null; - } - /** * Executes the given command in a VSCode terminal. If no active terminal * running Manim is found, a new terminal is spawned, and a new Manim @@ -117,6 +109,14 @@ export class ManimShell { return Promise.resolve(false); } + /** + * Resets the active shell such that a new terminal is created on the next + * command execution. + */ + public resetActiveShell() { + this.activeShell = null; + } + /** * Executes the command in the shell using shell integration if available, * otherwise using `sendText`. From 6c15e6e6553b305357f332cacf42b630b79cac89 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 17:08:43 +0200 Subject: [PATCH 19/58] Extract method hasActiveShell() --- src/manimShell.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 512436e0..831e1223 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -97,10 +97,10 @@ export class ManimShell { */ public async executeCommandEnsureActiveSession( command: string, waitUntilFinished = false): Promise { - if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { + if (!this.hasActiveShell()) { return Promise.resolve(false); } - this.exec(this.activeShell, command); + this.exec(this.activeShell as vscode.Terminal, command); if (waitUntilFinished) { await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); @@ -117,6 +117,17 @@ export class ManimShell { this.activeShell = null; } + /** + * Returns whether an active shell exists, i.e. a terminal that has an + * active ManimGL IPython session running. + * + * A shell that was previously used to run Manim, but has exited from the + * Manim session (IPython environment), is considered inactive. + */ + public hasActiveShell(): boolean { + return this.activeShell !== null && this.activeShell.exitStatus === undefined; + } + /** * Executes the command in the shell using shell integration if available, * otherwise using `sendText`. @@ -149,19 +160,16 @@ export class ManimShell { * be found. If a new shell is spawned, the Manim session is started at the * given line. * - * A shell that was previously used to run Manim, but has exited from the - * Manim session (IPython environment), is considered inactive. - * * @param startLine The line number in the active editor where the Manim * session should start in case a new terminal is spawned. * Also see: `startScene()`. */ private async retrieveOrInitActiveShell(startLine: number): Promise { - if (this.activeShell === null || this.activeShell.exitStatus !== undefined) { + if (!this.hasActiveShell()) { this.activeShell = vscode.window.createTerminal(); await startScene(startLine); } - return this.activeShell; + return this.activeShell as vscode.Terminal; } /** From e5d15769bb15059a9be5b33c522599cb6b7d5015 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 18:25:47 +0200 Subject: [PATCH 20/58] Exit scene if start scene is invoked while scene is running This fixes some weird dependencies between startScene() and the ManimShell and avoids recursion. --- src/extension.ts | 15 +----------- src/manimShell.ts | 31 +++++++++++++++++++++--- src/{startScene.ts => startStopScene.ts} | 23 +++++++++++++----- 3 files changed, 46 insertions(+), 23 deletions(-) rename src/{startScene.ts => startStopScene.ts} (85%) diff --git a/src/extension.ts b/src/extension.ts index 64429e52..7af51040 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,7 @@ import { ManimShell } from './manimShell'; import { ManimCell } from './manimCell'; import { ManimCellRanges } from './manimCellRanges'; import { previewCode } from './previewCode'; -import { startScene } from './startScene'; +import { startScene, exitScene } from './startStopScene'; export function activate(context: vscode.ExtensionContext) { @@ -119,19 +119,6 @@ function previewSelection() { previewCode(selectedText, selection.start.line); } -/** - * Runs the `exit()` command in the terminal to close the animation window - * and the IPython terminal. - */ -async function exitScene() { - const success = await ManimShell.instance.executeCommandEnsureActiveSession("exit()"); - if (success) { - ManimShell.instance.resetActiveShell(); - } else { - window.showErrorMessage('No active ManimGL scene found to exit.'); - } -} - /** * Runs the `clear()` command in the terminal to remove all objects from * the scene. diff --git a/src/manimShell.ts b/src/manimShell.ts index 831e1223..b0c0061f 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { window } from 'vscode'; -import { startScene } from './startScene'; +import { startScene, exitScene } from './startStopScene'; import { EventEmitter } from 'events'; /** @@ -106,7 +106,32 @@ export class ManimShell { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); } - return Promise.resolve(false); + return Promise.resolve(true); + } + + /** + * This command should only be used from within the actual `startScene()` + * method. It starts a new ManimGL scene in the terminal and waits until + * Manim is initialized. If an active shell is already running, it will be + * exited before starting the new scene. + * + * This method is needed to avoid recursion issues since the usual + * command execution will already start a new scene if no active shell is + * found. + * + * @param command The command to execute in the VSCode terminal. This is + * usually the command to start the ManimGL scene (at a specific line). + */ + public async executeStartCommand(command: string) { + if (this.hasActiveShell()) { + exitScene(); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + this.activeShell = window.createTerminal(); + this.exec(this.activeShell, command); + await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); } /** @@ -166,7 +191,7 @@ export class ManimShell { */ private async retrieveOrInitActiveShell(startLine: number): Promise { if (!this.hasActiveShell()) { - this.activeShell = vscode.window.createTerminal(); + this.activeShell = window.createTerminal(); await startScene(startLine); } return this.activeShell as vscode.Terminal; diff --git a/src/startScene.ts b/src/startStopScene.ts similarity index 85% rename from src/startScene.ts rename to src/startStopScene.ts index d155ed7f..2e3649ea 100644 --- a/src/startScene.ts +++ b/src/startStopScene.ts @@ -14,7 +14,9 @@ import { window } from 'vscode'; * args `--prerun --finder -w`. We don't do that here.) * * @param lineStart The line number where the scene should start. If omitted, - * the scene will start from the current cursor position. + * the scene will start from the current cursor position, which is the default + * behavior when the command is invoked from the command palette (and not from + * ManimShell). */ export async function startScene(lineStart?: number) { const editor = window.activeTextEditor; @@ -80,11 +82,7 @@ export async function startScene(lineStart?: number) { // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); // Run the command - // Note that this is the only point where "terminal" and "active Manim session" - // actually DON'T denote the same thing. At this point, we have a handle on - // an existing VSCode terminal, but that one is not running ManimGL yet. - // Therefore, we now spawn the interactive Manim session. - await ManimShell.instance.executeCommand(command, cursorLine, true); + await ManimShell.instance.executeStartCommand(command); // // Commented out - in case someone would like it. // // For us - it would require MacOS. Also - the effect is not desired. @@ -100,3 +98,16 @@ export async function startScene(lineStart?: number) { // terminal.show(); // } } + +/** + * Runs the `exit()` command in the terminal to close the animation window + * and the IPython terminal. + */ +export async function exitScene() { + const success = await ManimShell.instance.executeCommandEnsureActiveSession("exit()"); + if (success) { + ManimShell.instance.resetActiveShell(); + } else { + window.showErrorMessage('No active ManimGL scene found to exit.'); + } +} From 6edb6936207e1817f3cb657f5351fbfb9cf59a2f Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 18:27:41 +0200 Subject: [PATCH 21/58] Make method private --- src/manimShell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index b0c0061f..377cdef8 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -149,7 +149,7 @@ export class ManimShell { * A shell that was previously used to run Manim, but has exited from the * Manim session (IPython environment), is considered inactive. */ - public hasActiveShell(): boolean { + private hasActiveShell(): boolean { return this.activeShell !== null && this.activeShell.exitStatus === undefined; } From 947dc7a21d11daf76e64551c90eca5e5feeda5bc Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 18:30:29 +0200 Subject: [PATCH 22/58] Import Terminal directly --- src/manimShell.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 377cdef8..ea1d9cc9 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { window } from 'vscode'; +import { Terminal } from 'vscode'; import { startScene, exitScene } from './startStopScene'; import { EventEmitter } from 'events'; @@ -47,7 +48,7 @@ enum ManimShellEvent { export class ManimShell { static #instance: ManimShell; - private activeShell: vscode.Terminal | null = null; + private activeShell: Terminal | null = null; private eventEmitter = new EventEmitter(); private detectShellExecutionEnd = true; @@ -100,7 +101,7 @@ export class ManimShell { if (!this.hasActiveShell()) { return Promise.resolve(false); } - this.exec(this.activeShell as vscode.Terminal, command); + this.exec(this.activeShell as Terminal, command); if (waitUntilFinished) { await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); @@ -170,7 +171,7 @@ export class ManimShell { * @param shell The shell to execute the command in. * @param command The command to execute in the shell. */ - private exec(shell: vscode.Terminal, command: string) { + private exec(shell: Terminal, command: string) { this.detectShellExecutionEnd = false; if (shell.shellIntegration) { shell.shellIntegration.executeCommand(command); @@ -189,12 +190,12 @@ export class ManimShell { * session should start in case a new terminal is spawned. * Also see: `startScene()`. */ - private async retrieveOrInitActiveShell(startLine: number): Promise { + private async retrieveOrInitActiveShell(startLine: number): Promise { if (!this.hasActiveShell()) { this.activeShell = window.createTerminal(); await startScene(startLine); } - return this.activeShell as vscode.Terminal; + return this.activeShell as Terminal; } /** From 93f7560eedb393386cfb009b310b9b87fce2dc10 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 18:32:26 +0200 Subject: [PATCH 23/58] Clarify what ManimShell is --- src/manimShell.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index ea1d9cc9..a7ef1aeb 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -41,7 +41,10 @@ enum ManimShellEvent { * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands * are executed at the right place and spans a new Manim session if necessary. * - * The words "shell" and "terminal" are used interchangeably. + * The words "shell" and "terminal" are used interchangeably. "ManimShell" refers + * to a VSCode terminal that has a ManimGL IPython session running. The notion + * of "just a terminal", without a running Manim session, is not needed, as we + * always ensure that commands are run inside an active Manim session. * * This class is a singleton and should be accessed via `ManimShell.instance`. */ From cc81d62cca236190eaf42af011efe4eb3c78b23a Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 18:58:00 +0200 Subject: [PATCH 24/58] Add docstring for detectShellExecutionEnd --- src/manimShell.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index a7ef1aeb..8aa3847a 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -53,6 +53,20 @@ export class ManimShell { private activeShell: Terminal | null = null; private eventEmitter = new EventEmitter(); + + /** + * Whether to detect the end of a shell execution. + * + * We disable this while programmatically executing commands in the shell + * (over the whole duration of the command execution) since we only want to + * detect user-triggered "shell execution ends". The only such event is + * when the user somehow exists the IPython shell (e.g. Ctrl + D) or typing + * exit() etc. A "shell execution end" is not triggered when they run a + * command manually inside the active Manim session. + * + * If the user invokes the exit() command via the command pallette, we + * also reset the active shell. + */ private detectShellExecutionEnd = true; private constructor() { From 5321eaa9bcdc6fe545b7c9c9bb4b3f90add1e1a5 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 18:59:32 +0200 Subject: [PATCH 25/58] Fix typos in docstrings --- src/manimShell.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 8aa3847a..51c805df 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -177,13 +177,12 @@ export class ManimShell { * * This method is NOT asynchronous by design, as Manim commands run in the * IPython terminal that the VSCode API does not natively support. I.e. - * the event does not actually end, when the command has finished running, + * the event does not actually end when the command has finished running, * since we are still in the IPython environment. * * Note that this does not hold true for the initial setup of the terminal * and the first `checkpoint_paste()` call, which is why we disable the - * detection of the "shell execution end" when while the commands are - * issued. + * detection of the "shell execution end" while the commands are issued. * * @param shell The shell to execute the command in. * @param command The command to execute in the shell. From d035d69d43e45a122aba5641c2f0913754d07daf Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 19:07:46 +0200 Subject: [PATCH 26/58] Lock new command execution during startup --- src/manimShell.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index 51c805df..5c650ad2 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -54,6 +54,13 @@ export class ManimShell { private activeShell: Terminal | null = null; private eventEmitter = new EventEmitter(); + /** + * Whether the execution of a new command is locked. This is used to prevent + * multiple new scenes from being started at the same time, e.g. when users + * click on "Preview Manim Cell" multiple times in quick succession. + */ + private lockDuringStartup = false; + /** * Whether to detect the end of a shell execution. * @@ -85,6 +92,9 @@ export class ManimShell { * running Manim is found, a new terminal is spawned, and a new Manim * session is started in it before executing the given command. * + * This command is locked during startup to prevent multiple new scenes from + * being started at the same time, see `lockDuringStartup`. + * * @param command The command to execute in the VSCode terminal. * @param startLine The line number in the active editor where the Manim * session should start in case a new terminal is spawned. @@ -93,14 +103,24 @@ export class ManimShell { * has finished executing, e.g. when the whole animation has been previewed. */ public async executeCommand(command: string, startLine: number, waitUntilFinished = false) { + if (this.lockDuringStartup) { + return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); + } + const clipboardBuffer = await vscode.env.clipboard.readText(); + + this.lockDuringStartup = true; const shell = await this.retrieveOrInitActiveShell(startLine); + this.lockDuringStartup = false; + this.exec(shell, command); + if (waitUntilFinished) { await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); } + await vscode.env.clipboard.writeText(clipboardBuffer); } From a5f86e9a67388292465854d1305e0063025bd726 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 19:08:55 +0200 Subject: [PATCH 27/58] Don't be stingy with empty lines --- src/manimShell.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index 5c650ad2..c7f27e75 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -138,6 +138,7 @@ export class ManimShell { if (!this.hasActiveShell()) { return Promise.resolve(false); } + this.exec(this.activeShell as Terminal, command); if (waitUntilFinished) { await new Promise(resolve => { @@ -165,6 +166,7 @@ export class ManimShell { exitScene(); await new Promise(resolve => setTimeout(resolve, 2000)); } + this.activeShell = window.createTerminal(); this.exec(this.activeShell, command); await new Promise(resolve => { @@ -251,12 +253,15 @@ export class ManimShell { async (event: vscode.TerminalShellExecutionStartEvent) => { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { + if (data.match(MANIM_WELCOME_REGEX)) { this.activeShell = event.terminal; } + if (data.match(IPYTHON_CELL_START_REGEX)) { this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); } + if (data.match(ERROR_REGEX)) { this.activeShell?.show(); } @@ -265,9 +270,11 @@ export class ManimShell { window.onDidEndTerminalShellExecution( async (event: vscode.TerminalShellExecutionEndEvent) => { + if (!this.detectShellExecutionEnd) { return; } + if (event.terminal === this.activeShell) { this.resetActiveShell(); } From 835e8b6fd741987b0781a8f54d8e79529dc5d6f7 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 19:32:17 +0200 Subject: [PATCH 28/58] Avoid orphan VSCode terminals upon scene start --- src/manimShell.ts | 23 ++++++++++++++++------- src/startStopScene.ts | 10 +++++++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index c7f27e75..ac730932 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -77,6 +77,7 @@ export class ManimShell { private detectShellExecutionEnd = true; private constructor() { + this.activeShell = null; this.initiateTerminalDataReading(); } @@ -160,15 +161,22 @@ export class ManimShell { * * @param command The command to execute in the VSCode terminal. This is * usually the command to start the ManimGL scene (at a specific line). + * @param isRequestedForAnotherCommand Whether the command is executed + * because another command needed to have a new shell started. + * Only if the user manually starts a new scene, we want to exit a + * potentially already running scene beforehand. */ - public async executeStartCommand(command: string) { - if (this.hasActiveShell()) { - exitScene(); - await new Promise(resolve => setTimeout(resolve, 2000)); + public async executeStartCommand(command: string, isRequestedForAnotherCommand: boolean) { + if (!isRequestedForAnotherCommand) { + if (this.hasActiveShell()) { + exitScene(); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + this.activeShell = window.createTerminal(); } - - this.activeShell = window.createTerminal(); - this.exec(this.activeShell, command); + // We are sure that the active shell is set since it is invoked + // in `retrieveOrInitActiveShell()` or in the line above. + this.exec(this.activeShell as Terminal, command); await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); @@ -255,6 +263,7 @@ export class ManimShell { for await (const data of withoutAnsiCodes(stream)) { if (data.match(MANIM_WELCOME_REGEX)) { + console.log("Active shell seen"); this.activeShell = event.terminal; } diff --git a/src/startStopScene.ts b/src/startStopScene.ts index 2e3649ea..225fcabb 100644 --- a/src/startStopScene.ts +++ b/src/startStopScene.ts @@ -15,8 +15,11 @@ import { window } from 'vscode'; * * @param lineStart The line number where the scene should start. If omitted, * the scene will start from the current cursor position, which is the default - * behavior when the command is invoked from the command palette (and not from - * ManimShell). + * behavior when the command is invoked from the command palette. + * This parameter is set when invoked from ManimShell in order to spawn a new + * scene for another command at the given line number. You are probably doing + * something wrong if you invoke this method with lineStart !== undefined + * from somewhere else than the ManimShell. */ export async function startScene(lineStart?: number) { const editor = window.activeTextEditor; @@ -82,7 +85,8 @@ export async function startScene(lineStart?: number) { // await vscode.env.clipboard.writeText(command + " --prerun --finder -w"); // Run the command - await ManimShell.instance.executeStartCommand(command); + const isRequestedForAnotherCommand = (lineStart !== undefined); + await ManimShell.instance.executeStartCommand(command, isRequestedForAnotherCommand); // // Commented out - in case someone would like it. // // For us - it would require MacOS. Also - the effect is not desired. From f6045beb286c355586f821278299b341f3342cdf Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 20:09:45 +0200 Subject: [PATCH 29/58] Remove unnecessary statements --- src/manimShell.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index ac730932..30c60c1e 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -77,7 +77,6 @@ export class ManimShell { private detectShellExecutionEnd = true; private constructor() { - this.activeShell = null; this.initiateTerminalDataReading(); } @@ -263,7 +262,6 @@ export class ManimShell { for await (const data of withoutAnsiCodes(stream)) { if (data.match(MANIM_WELCOME_REGEX)) { - console.log("Active shell seen"); this.activeShell = event.terminal; } From bd40282a1134c54c57343bfe7e0d38a1f3a0edd3 Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 20:25:12 +0200 Subject: [PATCH 30/58] Listen to terminal data right from the beginning --- src/extension.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 7af51040..f99b1a0c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,9 @@ import { startScene, exitScene } from './startStopScene'; export function activate(context: vscode.ExtensionContext) { + // Trigger the Manim shell to start listening to the terminal + ManimShell.instance; + const previewManimCellCommand = vscode.commands.registerCommand( 'manim-notebook.previewManimCell', (cellCode?: string, startLine?: number) => { previewManimCell(cellCode, startLine); From 4d41027e5a31a9864eff139b1efea8636a7d417d Mon Sep 17 00:00:00 2001 From: Splines Date: Fri, 25 Oct 2024 20:44:58 +0200 Subject: [PATCH 31/58] Fix clipboard buffering --- src/manimShell.ts | 4 ---- src/previewCode.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 30c60c1e..d74ac1b8 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -107,8 +107,6 @@ export class ManimShell { return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); } - const clipboardBuffer = await vscode.env.clipboard.readText(); - this.lockDuringStartup = true; const shell = await this.retrieveOrInitActiveShell(startLine); this.lockDuringStartup = false; @@ -120,8 +118,6 @@ export class ManimShell { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); } - - await vscode.env.clipboard.writeText(clipboardBuffer); } /** diff --git a/src/previewCode.ts b/src/previewCode.ts index 6bf313d9..9dcf90ef 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -47,7 +47,7 @@ export async function previewCode(code: string, startLine: number): Promise Date: Sat, 26 Oct 2024 20:04:47 +0200 Subject: [PATCH 32/58] Prevent multiple commands from running on MacOS --- src/manimShell.ts | 49 +++++++++++++++++++++++++++++++++++++++++-- src/previewCode.ts | 41 ++++++++++-------------------------- src/startStopScene.ts | 3 ++- 3 files changed, 60 insertions(+), 33 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index d74ac1b8..d2b4f947 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -37,6 +37,10 @@ enum ManimShellEvent { IPYTHON_CELL_FINISHED = 'ipythonCellFinished', } +const MAC_OS_MULTIPLE_COMMANDS_ERROR = `On MacOS, we don't support running` + + ` multiple Manim commands at the same time. Please wait until the` + + ` current command has finished.`; + /** * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands * are executed at the right place and spans a new Manim session if necessary. @@ -61,6 +65,15 @@ export class ManimShell { */ private lockDuringStartup = false; + /** + * Whether to lock the execution of a new command while another command is + * currently running. On MacOS, we do lock since the IPython terminal *exits* + * when sending Ctrl+C instead of just interrupting the current command. + * See issue #16: https://github.com/bhoov/manim-notebook/issues/16 + */ + private shouldLockDuringCommandExecution = false; + private isExecutingCommand = false; + /** * Whether to detect the end of a shell execution. * @@ -78,6 +91,11 @@ export class ManimShell { private constructor() { this.initiateTerminalDataReading(); + + // on MacOS + if (process.platform === "darwin") { + this.shouldLockDuringCommandExecution = true; + } } public static get instance(): ManimShell { @@ -102,22 +120,36 @@ export class ManimShell { * @param [waitUntilFinished=false] Whether to wait until the actual command * has finished executing, e.g. when the whole animation has been previewed. */ - public async executeCommand(command: string, startLine: number, waitUntilFinished = false) { + public async executeCommand(command: string, startLine: number, + waitUntilFinished = false, onCommandIssued?: () => void) { if (this.lockDuringStartup) { return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); } + if (this.shouldLockDuringCommandExecution && this.isExecutingCommand) { + return vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); + } + + this.isExecutingCommand = true; this.lockDuringStartup = true; const shell = await this.retrieveOrInitActiveShell(startLine); this.lockDuringStartup = false; this.exec(shell, command); + if (onCommandIssued) { + onCommandIssued(); + } if (waitUntilFinished) { await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); } + + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + console.log("Finished command execution."); + this.isExecutingCommand = false; + }); } /** @@ -126,14 +158,23 @@ export class ManimShell { * @param command The command to execute in the VSCode terminal. * @param [waitUntilFinished=false] Whether to wait until the actual command * has finished executing, e.g. when the whole animation has been previewed. + * @param [forceExecute=false] Whether to force the execution of the command + * even if another command is currently running. This is only necessary when + * the `shouldLockDuringCommandExecution` is set to true. * @returns A boolean indicating whether an active shell was found or not. * If no active shell was found, the command was also not executed. */ public async executeCommandEnsureActiveSession( - command: string, waitUntilFinished = false): Promise { + command: string, waitUntilFinished = false, forceExecute = false): Promise { if (!this.hasActiveShell()) { return Promise.resolve(false); } + if (this.shouldLockDuringCommandExecution && !forceExecute && this.isExecutingCommand) { + vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); + return Promise.resolve(true); + } + + this.isExecutingCommand = true; this.exec(this.activeShell as Terminal, command); if (waitUntilFinished) { @@ -141,6 +182,10 @@ export class ManimShell { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); }); } + + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + this.isExecutingCommand = false; + }); return Promise.resolve(true); } diff --git a/src/previewCode.ts b/src/previewCode.ts index 9dcf90ef..0bf76b60 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -6,21 +6,6 @@ const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; // \x0C: is Ctrl + L // \x1b: https://github.com/bhoov/manim-notebook/issues/18#issuecomment-2431146809 -/** - * Whether the extension is currently executing a Manim command. - * - * Note that this is not capturing whether the `checkpoint_paste()` command is - * still running. Instead, it only captures whether reading/writing to clipboard - * is currently happening to prevent unpredictable behavior. - * - * We don't need to capture the state of `checkpoint_paste()` because users - * might actually want to preview a cell (or another one) again from the start - * even though the animation is still running. With the new VSCode terminal - * shell integration, it will automatically send a `Ctrl + C` to the terminal - * when a new command is sent, so the previous command will be interrupted. - */ -let isExecuting = false; - /** * Interactively previews the given Manim code by means of the * `checkpoint_paste()` method from Manim. @@ -37,26 +22,22 @@ let isExecuting = false; * @param code The code to preview (e.g. from a Manim cell or from a custom selection). */ export async function previewCode(code: string, startLine: number): Promise { - if (isExecuting) { - vscode.window.showInformationMessage('Please wait a few seconds, then try again.'); - return; - } - isExecuting = true; - try { const clipboardBuffer = await vscode.env.clipboard.readText(); await vscode.env.clipboard.writeText(code); - await ManimShell.instance.executeCommand(PREVIEW_COMMAND, startLine); - - // Restore original clipboard content - const timeout = vscode.workspace.getConfiguration("manim-notebook").clipboardTimeout; - setTimeout(async () => { - await vscode.env.clipboard.writeText(clipboardBuffer); - }, timeout); + await ManimShell.instance.executeCommand( + PREVIEW_COMMAND, startLine, true, + () => restoreClipboard(clipboardBuffer) + ); } catch (error) { vscode.window.showErrorMessage(`Error: ${error}`); - } finally { - isExecuting = false; } } + +function restoreClipboard(clipboardBuffer: string) { + const timeout = vscode.workspace.getConfiguration("manim-notebook").clipboardTimeout; + setTimeout(async () => { + await vscode.env.clipboard.writeText(clipboardBuffer); + }, timeout); +} diff --git a/src/startStopScene.ts b/src/startStopScene.ts index 225fcabb..2d17d0a9 100644 --- a/src/startStopScene.ts +++ b/src/startStopScene.ts @@ -108,7 +108,8 @@ export async function startScene(lineStart?: number) { * and the IPython terminal. */ export async function exitScene() { - const success = await ManimShell.instance.executeCommandEnsureActiveSession("exit()"); + const success = await ManimShell.instance.executeCommandEnsureActiveSession( + "exit()", false, true); if (success) { ManimShell.instance.resetActiveShell(); } else { From 10118f0c9f2ba584d901bc2d2602e41d4622f208 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 20:39:59 +0200 Subject: [PATCH 33/58] Add progress notification to scene startup --- src/manimShell.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index d2b4f947..c7d4e9b8 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -214,11 +214,18 @@ export class ManimShell { } this.activeShell = window.createTerminal(); } - // We are sure that the active shell is set since it is invoked - // in `retrieveOrInitActiveShell()` or in the line above. - this.exec(this.activeShell as Terminal, command); - await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + + await window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Starting Manim...", + cancellable: false + }, async (progress, token) => { + // We are sure that the active shell is set since it is invoked + // in `retrieveOrInitActiveShell()` or in the line above. + this.exec(this.activeShell as Terminal, command); + await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); }); } From 072fa747b761d4b6dc7b99956ab420c600ce4fce Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 20:41:00 +0200 Subject: [PATCH 34/58] Remove console log --- src/manimShell.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index d2b4f947..9a3b0537 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -147,7 +147,6 @@ export class ManimShell { } this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { - console.log("Finished command execution."); this.isExecutingCommand = false; }); } From ef553fe19bf4c6b27a104687c825fe5a3cbea192 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 22:57:37 +0200 Subject: [PATCH 35/58] Init preview progress notification --- src/manimShell.ts | 25 ++++++++++++++++--- src/previewCode.ts | 61 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 9515392f..ad81d323 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -35,6 +35,8 @@ enum ManimShellEvent { * IPYTHON_CELL_START_REGEX is matched. */ IPYTHON_CELL_FINISHED = 'ipythonCellFinished', + + DATA = 'generalData' } const MAC_OS_MULTIPLE_COMMANDS_ERROR = `On MacOS, we don't support running` @@ -121,7 +123,8 @@ export class ManimShell { * has finished executing, e.g. when the whole animation has been previewed. */ public async executeCommand(command: string, startLine: number, - waitUntilFinished = false, onCommandIssued?: () => void) { + waitUntilFinished = false, onCommandIssued?: () => void, + onData?: (data: string) => void) { if (this.lockDuringStartup) { return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); } @@ -135,6 +138,14 @@ export class ManimShell { const shell = await this.retrieveOrInitActiveShell(startLine); this.lockDuringStartup = false; + const dataListener = (data: string) => { + if (onData) { + onData(data); + } + }; + + this.eventEmitter.on(ManimShellEvent.DATA, dataListener); + this.exec(shell, command); if (onCommandIssued) { onCommandIssued(); @@ -142,12 +153,16 @@ export class ManimShell { if (waitUntilFinished) { await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + // first IPython cell is actually printing the issued command + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); }); } this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { this.isExecutingCommand = false; + this.eventEmitter.off(ManimShellEvent.DATA, dataListener); }); } @@ -178,7 +193,10 @@ export class ManimShell { this.exec(this.activeShell as Terminal, command); if (waitUntilFinished) { await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + // first IPython cell is actually printing the issued command + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); }); } @@ -307,6 +325,7 @@ export class ManimShell { async (event: vscode.TerminalShellExecutionStartEvent) => { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { + this.eventEmitter.emit(ManimShellEvent.DATA, data); if (data.match(MANIM_WELCOME_REGEX)) { this.activeShell = event.terminal; diff --git a/src/previewCode.ts b/src/previewCode.ts index 0bf76b60..d6e89dad 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { ManimShell } from './manimShell'; import { window } from 'vscode'; -const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; +const PREVIEW_COMMAND = `checkpoint_paste()`; // \x0C: is Ctrl + L // \x1b: https://github.com/bhoov/manim-notebook/issues/18#issuecomment-2431146809 @@ -26,10 +26,61 @@ export async function previewCode(code: string, startLine: number): Promise restoreClipboard(clipboardBuffer) - ); + let currentSceneName: string | undefined = undefined; + let currentProgress: number = 0; + + await window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Previewing Manim", + cancellable: false + }, async (progress, token) => { + progress.report({ increment: 0 }); + + await ManimShell.instance.executeCommand( + PREVIEW_COMMAND, startLine, true, + () => restoreClipboard(clipboardBuffer), + (data) => { + // TODO: Refactor (!!!) + // TODO: Make sure progress is reset when current command + // is somehow aborted, e.g. when clicking on preview + // while another preview is running. + if (!data.includes("%")) { + return; + } + const progressString = data.match(/\b\d{1,2}(?=\s?%)/)?.[0]; + if (!progressString) { + return; + } + + const newProgress = parseInt(progressString); + console.log(`✅ ${newProgress}`); + + let progressIncrement = newProgress - currentProgress; + + const split = data.split(" "); + if (split.length < 2) { + return; + } + let sceneName = data.split(" ")[1]; + // remove last char which is a ":" + sceneName = sceneName.substring(0, sceneName.length - 1); + if (sceneName !== currentSceneName) { + if (currentSceneName === undefined) { + // Reset progress to 0 + progressIncrement = -currentProgress; + } + currentSceneName = sceneName; + } + + currentProgress = newProgress; + + progress.report({ + increment: progressIncrement, + message: sceneName + }); + } + ); + }); } catch (error) { vscode.window.showErrorMessage(`Error: ${error}`); } From 3220c55f11cc84f6b5e985b0e73625ecec1c1d42 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 22:58:08 +0200 Subject: [PATCH 36/58] Delete console logs --- src/previewCode.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index d6e89dad..1627fa9f 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -53,8 +53,6 @@ export async function previewCode(code: string, startLine: number): Promise Date: Sat, 26 Oct 2024 23:20:47 +0200 Subject: [PATCH 37/58] Introduce keyboard interrupt event --- src/manimShell.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index ad81d323..bf3732f8 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -16,6 +16,11 @@ const ANSI_CONTROL_SEQUENCE_REGEX = /(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x */ const IPYTHON_CELL_START_REGEX = /^\s*In \[\d+\]:/m; +/** + * Regular expression to match a KeyboardInterrupt. + */ +const KEYBOARD_INTERRUPT_REGEX = /^\s*KeyboardInterrupt/m; + /** * Regular expression to match an error message in the terminal. For any error * message, IPython prints "Cell In[], line " to a new line. @@ -36,6 +41,12 @@ enum ManimShellEvent { */ IPYTHON_CELL_FINISHED = 'ipythonCellFinished', + /** + * Event emitted when a keyboard interrupt is detected in the terminal, e.g. + * when `Ctrl+C` is pressed to stop the current command execution. + */ + KEYBOARD_INTERRUPT = 'keyboardInterrupt', + DATA = 'generalData' } @@ -153,6 +164,7 @@ export class ManimShell { if (waitUntilFinished) { await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.KEYBOARD_INTERRUPT, resolve); // first IPython cell is actually printing the issued command this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); @@ -193,6 +205,7 @@ export class ManimShell { this.exec(this.activeShell as Terminal, command); if (waitUntilFinished) { await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.KEYBOARD_INTERRUPT, resolve); // first IPython cell is actually printing the issued command this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); @@ -231,7 +244,7 @@ export class ManimShell { } this.activeShell = window.createTerminal(); } - + await window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Starting Manim...", @@ -331,6 +344,10 @@ export class ManimShell { this.activeShell = event.terminal; } + if (data.match(KEYBOARD_INTERRUPT_REGEX)) { + this.eventEmitter.emit(ManimShellEvent.KEYBOARD_INTERRUPT); + } + if (data.match(IPYTHON_CELL_START_REGEX)) { this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); } From 91dd6f897c6d98cce04d58928d50e856b2052d51 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 23:51:14 +0200 Subject: [PATCH 38/58] Restore checkpoint paste command --- src/previewCode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index 1627fa9f..af85e7bd 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { ManimShell } from './manimShell'; import { window } from 'vscode'; -const PREVIEW_COMMAND = `checkpoint_paste()`; +const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; // \x0C: is Ctrl + L // \x1b: https://github.com/bhoov/manim-notebook/issues/18#issuecomment-2431146809 From 4bd15b66c5ee8d2350023eb187484ee725117f43 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 23:52:27 +0200 Subject: [PATCH 39/58] Extract method waitUntilCommandFinished & unify behavior --- src/manimShell.ts | 65 ++++++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index bf3732f8..f8b22ee7 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -139,8 +139,12 @@ export class ManimShell { if (this.lockDuringStartup) { return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); } - if (this.shouldLockDuringCommandExecution && this.isExecutingCommand) { - return vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); + if (this.isExecutingCommand) { + if (this.shouldLockDuringCommandExecution) { + return vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); + } + this.sendKeyboardInterrupt(); + await new Promise(resolve => setTimeout(resolve, 650)); } this.isExecutingCommand = true; @@ -163,16 +167,9 @@ export class ManimShell { } if (waitUntilFinished) { - await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.KEYBOARD_INTERRUPT, resolve); - // first IPython cell is actually printing the issued command - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); - }); - }); + await this.waitUntilCommandFinished(); } - - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + this.waitUntilCommandFinished(() => { this.isExecutingCommand = false; this.eventEmitter.off(ManimShellEvent.DATA, dataListener); }); @@ -195,27 +192,27 @@ export class ManimShell { if (!this.hasActiveShell()) { return Promise.resolve(false); } - if (this.shouldLockDuringCommandExecution && !forceExecute && this.isExecutingCommand) { - vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); - return Promise.resolve(true); + if (this.isExecutingCommand) { + if (this.shouldLockDuringCommandExecution && !forceExecute) { + vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); + return Promise.resolve(true); + } + this.sendKeyboardInterrupt(); + await new Promise(resolve => setTimeout(resolve, 650)); } + this.isExecutingCommand = true; this.exec(this.activeShell as Terminal, command); + if (waitUntilFinished) { - await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.KEYBOARD_INTERRUPT, resolve); - // first IPython cell is actually printing the issued command - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); - }); - }); + await this.waitUntilCommandFinished(); } - - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + this.waitUntilCommandFinished(() => { this.isExecutingCommand = false; }); + return Promise.resolve(true); } @@ -321,6 +318,26 @@ export class ManimShell { return this.activeShell as Terminal; } + private async waitUntilCommandFinished(callback?: () => void) { + await new Promise(resolve => { + this.eventEmitter.once(ManimShellEvent.KEYBOARD_INTERRUPT, resolve); + + // The first IPython cell is actually printing the issued command, + // which we want to just ignore, and then we wait for the actual + // command to finish. + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { + this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); + }); + }); + if (callback) { + callback(); + } + } + + private async sendKeyboardInterrupt() { + this.activeShell?.sendText('\x03'); // send `Ctrl+C` + } + /** * Inits the reading of data from the terminal and issues actions/events * based on the data received: @@ -347,7 +364,7 @@ export class ManimShell { if (data.match(KEYBOARD_INTERRUPT_REGEX)) { this.eventEmitter.emit(ManimShellEvent.KEYBOARD_INTERRUPT); } - + if (data.match(IPYTHON_CELL_START_REGEX)) { this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); } From b52f0e4e0ba4e390b4c2c0468543d9523c469587 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 26 Oct 2024 23:56:24 +0200 Subject: [PATCH 40/58] Remove fulfilled TODO note --- src/previewCode.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index af85e7bd..8f44d556 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -41,9 +41,6 @@ export async function previewCode(code: string, startLine: number): Promise restoreClipboard(clipboardBuffer), (data) => { // TODO: Refactor (!!!) - // TODO: Make sure progress is reset when current command - // is somehow aborted, e.g. when clicking on preview - // while another preview is running. if (!data.includes("%")) { return; } From e8964402032397a2b3d8b9032fe3939baa79f162 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 00:34:31 +0200 Subject: [PATCH 41/58] Init PreviewProgress class with more sophisticated handling --- src/previewCode.ts | 135 +++++++++++++++++++++++++++++---------------- 1 file changed, 88 insertions(+), 47 deletions(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index 8f44d556..b4151f5a 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { ManimShell } from './manimShell'; import { window } from 'vscode'; +import { EventEmitter } from 'events'; const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; // \x0C: is Ctrl + L @@ -29,53 +30,22 @@ export async function previewCode(code: string, startLine: number): Promise { - progress.report({ increment: 0 }); - - await ManimShell.instance.executeCommand( - PREVIEW_COMMAND, startLine, true, - () => restoreClipboard(clipboardBuffer), - (data) => { - // TODO: Refactor (!!!) - if (!data.includes("%")) { - return; - } - const progressString = data.match(/\b\d{1,2}(?=\s?%)/)?.[0]; - if (!progressString) { - return; - } - - const newProgress = parseInt(progressString); - let progressIncrement = newProgress - currentProgress; - - const split = data.split(" "); - if (split.length < 2) { - return; - } - let sceneName = data.split(" ")[1]; - // remove last char which is a ":" - sceneName = sceneName.substring(0, sceneName.length - 1); - if (sceneName !== currentSceneName) { - if (currentSceneName === undefined) { - // Reset progress to 0 - progressIncrement = -currentProgress; - } - currentSceneName = sceneName; - } - - currentProgress = newProgress; - - progress.report({ - increment: progressIncrement, - message: sceneName - }); - } - ); - }); + let progress: PreviewProgress | undefined; + + await ManimShell.instance.executeCommand( + PREVIEW_COMMAND, startLine, true, + () => { + // Executed after command is sent to terminal + restoreClipboard(clipboardBuffer); + progress = new PreviewProgress(); + }, + (data) => { + progress?.reportOnData(data); + } + ); + + progress?.finish(); + } catch (error) { vscode.window.showErrorMessage(`Error: ${error}`); } @@ -87,3 +57,74 @@ function restoreClipboard(clipboardBuffer: string) { await vscode.env.clipboard.writeText(clipboardBuffer); }, timeout); } + +class PreviewProgress { + private eventEmitter = new EventEmitter(); + private FINISH_EVENT = "finished"; + private REPORT_EVENT = "report"; + + private progress: number = 0; + private sceneName: string | undefined; + + constructor() { + vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Previewing Manim", + cancellable: false + }, async (progressIndicator, token) => { + await new Promise((resolve) => { + this.eventEmitter.on(this.FINISH_EVENT, resolve); + this.eventEmitter.on(this.REPORT_EVENT, + (data: { increment: number, message: string }) => { + this.progress += data.increment; + progressIndicator.report( + { + increment: data.increment, + message: data.message + }); + }); + }); + }); + } + + public reportOnData(data: string) { + const newProgress = this.extractProgressFromString(data); + if (newProgress === -1) { + return; + } + let progressIncrement = newProgress - this.progress; + + const split = data.split(" "); + if (split.length < 2) { + return; + } + let newSceneName = data.split(" ")[1]; + // remove last char which is a ":" + newSceneName = newSceneName.substring(0, newSceneName.length - 1); + if (newSceneName !== this.sceneName) { + progressIncrement = -this.progress; // reset progress to 0 + this.sceneName = newSceneName; + } + + this.eventEmitter.emit(this.REPORT_EVENT, { + increment: progressIncrement, + message: newSceneName + }); + } + + public finish() { + this.eventEmitter.emit(this.FINISH_EVENT); + } + + private extractProgressFromString(data: string): number { + if (!data.includes("%")) { + return -1; + } + const progressString = data.match(/\b\d{1,2}(?=\s?%)/)?.[0]; + if (!progressString) { + return -1; + } + + return parseInt(progressString); + } +} From 715e9fdbb73c7cd63fdeba92e5bdf757f54bc948 Mon Sep 17 00:00:00 2001 From: Ben Hoover <24350185+bhoov@users.noreply.github.com> Date: Sat, 26 Oct 2024 20:50:27 -0400 Subject: [PATCH 42/58] Update MacOS error message --- src/manimShell.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 9a3b0537..3dea4013 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -37,9 +37,10 @@ enum ManimShellEvent { IPYTHON_CELL_FINISHED = 'ipythonCellFinished', } -const MAC_OS_MULTIPLE_COMMANDS_ERROR = `On MacOS, we don't support running` - + ` multiple Manim commands at the same time. Please wait until the` - + ` current command has finished.`; +const MAC_OS_MULTIPLE_COMMANDS_ERROR = + `Simultaneous Manim commands are not currently supported on macOS. ` + + `Please wait for the current operations to finish before initiating ` + + `a new command.`; /** * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands From 65ed0c18b1829e67e6932698c8dbd6b924d52f9d Mon Sep 17 00:00:00 2001 From: Ben Hoover <24350185+bhoov@users.noreply.github.com> Date: Sat, 26 Oct 2024 20:53:10 -0400 Subject: [PATCH 43/58] (fix MacOS typo) --- src/manimShell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 3dea4013..a5a26cfc 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -38,7 +38,7 @@ enum ManimShellEvent { } const MAC_OS_MULTIPLE_COMMANDS_ERROR = - `Simultaneous Manim commands are not currently supported on macOS. ` + `Simultaneous Manim commands are not currently supported on MacOS. ` + `Please wait for the current operations to finish before initiating ` + `a new command.`; From 6cec111093d9382ce54ddd6259839ad589e4f15c Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 11:50:19 +0100 Subject: [PATCH 44/58] Wrap callback in interface (for future PR) --- src/manimShell.ts | 21 +++++++++++++++++---- src/previewCode.ts | 7 +++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index a5a26cfc..27b04366 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -42,6 +42,19 @@ const MAC_OS_MULTIPLE_COMMANDS_ERROR = + `Please wait for the current operations to finish before initiating ` + `a new command.`; +/** + * Event handler for command execution events. + */ +export interface CommandExecutionEventHandler { + /** + * Callback that is invoked when the command is issued, i.e. sent to the + * terminal. At this point, the command is probably not yet finished + * executing. + */ + onCommandIssued?: () => void; +} + + /** * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands * are executed at the right place and spans a new Manim session if necessary. @@ -120,9 +133,11 @@ export class ManimShell { * Also see `startScene()`. * @param [waitUntilFinished=false] Whether to wait until the actual command * has finished executing, e.g. when the whole animation has been previewed. + * @param handler Event handler for command execution events. See the + * interface `CommandExecutionEventHandler`. */ public async executeCommand(command: string, startLine: number, - waitUntilFinished = false, onCommandIssued?: () => void) { + waitUntilFinished = false, handler?: CommandExecutionEventHandler) { if (this.lockDuringStartup) { return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); } @@ -137,9 +152,7 @@ export class ManimShell { this.lockDuringStartup = false; this.exec(shell, command); - if (onCommandIssued) { - onCommandIssued(); - } + handler?.onCommandIssued?.(); if (waitUntilFinished) { await new Promise(resolve => { diff --git a/src/previewCode.ts b/src/previewCode.ts index 0bf76b60..b0a8eeff 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -27,8 +27,11 @@ export async function previewCode(code: string, startLine: number): Promise restoreClipboard(clipboardBuffer) + PREVIEW_COMMAND, startLine, true, { + onCommandIssued: () => { + restoreClipboard(clipboardBuffer); + } + } ); } catch (error) { vscode.window.showErrorMessage(`Error: ${error}`); From 1ca9fbccac714929ba5db60bc5b14136d3d23b6b Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 12:31:37 +0100 Subject: [PATCH 45/58] Refactor execute command methods (merge into one) --- src/extension.ts | 8 +-- src/manimShell.ts | 137 +++++++++++++++++++++++++----------------- src/startStopScene.ts | 14 ++--- 3 files changed, 92 insertions(+), 67 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index f99b1a0c..d6ebc681 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -127,10 +127,10 @@ function previewSelection() { * the scene. */ async function clearScene() { - const success = await ManimShell.instance.executeCommandEnsureActiveSession("clear()"); - if (!success) { - window.showErrorMessage('No active ManimGL scene found to remove objects from.'); - } + await ManimShell.instance.executeCommandEnsureActiveSession("clear()"); + // if (!success) { + // window.showErrorMessage('No active ManimGL scene found to remove objects from.'); + // } } /** diff --git a/src/manimShell.ts b/src/manimShell.ts index 27b04366..8d26265e 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -37,11 +37,6 @@ enum ManimShellEvent { IPYTHON_CELL_FINISHED = 'ipythonCellFinished', } -const MAC_OS_MULTIPLE_COMMANDS_ERROR = - `Simultaneous Manim commands are not currently supported on MacOS. ` - + `Please wait for the current operations to finish before initiating ` - + `a new command.`; - /** * Event handler for command execution events. */ @@ -120,76 +115,107 @@ export class ManimShell { } /** - * Executes the given command in a VSCode terminal. If no active terminal - * running Manim is found, a new terminal is spawned, and a new Manim - * session is started in it before executing the given command. + * Executes the given command. If no active terminal running Manim is found, + * a new terminal is spawned, and a new Manim session is started in it + * before executing the given command. * * This command is locked during startup to prevent multiple new scenes from * being started at the same time, see `lockDuringStartup`. * + * For params explanations, see the docs for `execCommand()`. + */ + public async executeCommand( + command: string, startLine: number, waitUntilFinished = false, + handler?: CommandExecutionEventHandler + ) { + await this.execCommand( + command, waitUntilFinished, false, false, startLine, handler); + } + + /** + * Executes the given command, but only if an active ManimGL shell exists. + * + * For params explanations, see the docs for `execCommand()`. + */ + public async executeCommandEnsureActiveSession( + command: string, waitUntilFinished = false, forceExecute = false + ) { + await this.execCommand( + command, waitUntilFinished, forceExecute, true, undefined, undefined); + } + + /** + * Executes a given command and bundles many different behaviors and options. + * + * This method is internal and only exposed via other public methods that + * select a specific behavior. + * * @param command The command to execute in the VSCode terminal. + * @param waitUntilFinished Whether to wait until the actual command has + * finished executing, e.g. when the whole animation has been previewed. + * If set to false (default), we only wait until the command has been issued, + * i.e. sent to the terminal. + * @param forceExecute Whether to force the execution of the command even if + * another command is currently running. This is only taken into account + * when the `shouldLockDuringCommandExecution` is set to true. + * @param errorOnNoActiveShell Whether to execute the command only if an + * active shell exists. If no active shell is found, a warning message is + * shown to the user. * @param startLine The line number in the active editor where the Manim * session should start in case a new terminal is spawned. - * Also see `startScene()`. - * @param [waitUntilFinished=false] Whether to wait until the actual command - * has finished executing, e.g. when the whole animation has been previewed. + * Also see `startScene(). You MUST set a startLine if `errorOnNoActiveShell` + * is set to false, since the method might invoke a new shell in this case + * and needs to know at which line to start it. * @param handler Event handler for command execution events. See the * interface `CommandExecutionEventHandler`. */ - public async executeCommand(command: string, startLine: number, - waitUntilFinished = false, handler?: CommandExecutionEventHandler) { - if (this.lockDuringStartup) { - return vscode.window.showWarningMessage("Manim is currently starting. Please wait a moment."); + private async execCommand( + command: string, + waitUntilFinished: boolean, + forceExecute: boolean, + errorOnNoActiveShell: boolean, + startLine?: number, + handler?: CommandExecutionEventHandler + ) { + if (!errorOnNoActiveShell && startLine === undefined) { + // should never happen if method is called correctly + window.showErrorMessage("Start line not set. Internal extension error."); + return; } - if (this.shouldLockDuringCommandExecution && this.isExecutingCommand) { - return vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); - } - - this.isExecutingCommand = true; - this.lockDuringStartup = true; - const shell = await this.retrieveOrInitActiveShell(startLine); - this.lockDuringStartup = false; - - this.exec(shell, command); - handler?.onCommandIssued?.(); - - if (waitUntilFinished) { - await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); - }); + if (this.lockDuringStartup) { + window.showWarningMessage("Manim is currently starting. Please wait a moment."); + return; } - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { - this.isExecutingCommand = false; - }); - } - - /** - * Executes the given command, but only if an active ManimGL shell exists. - * - * @param command The command to execute in the VSCode terminal. - * @param [waitUntilFinished=false] Whether to wait until the actual command - * has finished executing, e.g. when the whole animation has been previewed. - * @param [forceExecute=false] Whether to force the execution of the command - * even if another command is currently running. This is only necessary when - * the `shouldLockDuringCommandExecution` is set to true. - * @returns A boolean indicating whether an active shell was found or not. - * If no active shell was found, the command was also not executed. - */ - public async executeCommandEnsureActiveSession( - command: string, waitUntilFinished = false, forceExecute = false): Promise { - if (!this.hasActiveShell()) { - return Promise.resolve(false); + if (errorOnNoActiveShell && !this.hasActiveShell()) { + window.showWarningMessage( + "No active Manim session found, which is required for this command."); + return; } + if (this.shouldLockDuringCommandExecution && !forceExecute && this.isExecutingCommand) { - vscode.window.showWarningMessage(MAC_OS_MULTIPLE_COMMANDS_ERROR); - return Promise.resolve(true); + window.showWarningMessage( + `Simultaneous Manim commands are not currently supported on MacOS. ` + + `Please wait for the current operations to finish before initiating ` + + `a new command.`); + return; } this.isExecutingCommand = true; - this.exec(this.activeShell as Terminal, command); + let shell: Terminal; + if (errorOnNoActiveShell) { + shell = this.activeShell as Terminal; + } else { + this.lockDuringStartup = true; + shell = await this.retrieveOrInitActiveShell(startLine!); + this.lockDuringStartup = false; + } + + this.exec(shell, command); + handler?.onCommandIssued?.(); + if (waitUntilFinished) { await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); @@ -199,7 +225,6 @@ export class ManimShell { this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { this.isExecutingCommand = false; }); - return Promise.resolve(true); } /** diff --git a/src/startStopScene.ts b/src/startStopScene.ts index 2d17d0a9..4b507e5f 100644 --- a/src/startStopScene.ts +++ b/src/startStopScene.ts @@ -108,11 +108,11 @@ export async function startScene(lineStart?: number) { * and the IPython terminal. */ export async function exitScene() { - const success = await ManimShell.instance.executeCommandEnsureActiveSession( - "exit()", false, true); - if (success) { - ManimShell.instance.resetActiveShell(); - } else { - window.showErrorMessage('No active ManimGL scene found to exit.'); - } + await ManimShell.instance.executeCommandEnsureActiveSession("exit()", false, true); + // TODO: Implement error handling, and if no error resetActiveShell() (!!!) + // if (success) { + // ManimShell.instance.resetActiveShell(); + // } else { + // window.showErrorMessage('No active ManimGL scene found to exit.'); + // } } From d20d94749241429dc271af20635c86335f13bd9a Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 12:41:39 +0100 Subject: [PATCH 46/58] Throw error if no active session found (but required) --- src/extension.ts | 9 +++++---- src/manimShell.ts | 25 +++++++++++++++++++------ src/startStopScene.ts | 12 +++++------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index d6ebc681..4078ee35 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -127,10 +127,11 @@ function previewSelection() { * the scene. */ async function clearScene() { - await ManimShell.instance.executeCommandEnsureActiveSession("clear()"); - // if (!success) { - // window.showErrorMessage('No active ManimGL scene found to remove objects from.'); - // } + try { + await ManimShell.instance.executeCommandErrorOnNoActiveSession("clear()"); + } catch (NoActiveSessionError) { + window.showErrorMessage('No active Manim session found to remove objects from.'); + } } /** diff --git a/src/manimShell.ts b/src/manimShell.ts index 8d26265e..24dd2d56 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -49,6 +49,16 @@ export interface CommandExecutionEventHandler { onCommandIssued?: () => void; } +/** + * Error thrown when no active shell is found, but an active shell is required + * for the command execution. + */ +export class NoActiveShellError extends Error { + constructor() { + super(); + this.name = "NoActiveShellError"; + } +} /** * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands @@ -134,10 +144,12 @@ export class ManimShell { /** * Executes the given command, but only if an active ManimGL shell exists. + * Otherwise throws a `NoActiveShellError`. * * For params explanations, see the docs for `execCommand()`. + * @throws NoActiveShellError If no active shell is found. */ - public async executeCommandEnsureActiveSession( + public async executeCommandErrorOnNoActiveSession( command: string, waitUntilFinished = false, forceExecute = false ) { await this.execCommand( @@ -159,8 +171,7 @@ export class ManimShell { * another command is currently running. This is only taken into account * when the `shouldLockDuringCommandExecution` is set to true. * @param errorOnNoActiveShell Whether to execute the command only if an - * active shell exists. If no active shell is found, a warning message is - * shown to the user. + * active shell exists. If no active shell is found, an error is thrown. * @param startLine The line number in the active editor where the Manim * session should start in case a new terminal is spawned. * Also see `startScene(). You MUST set a startLine if `errorOnNoActiveShell` @@ -168,6 +179,10 @@ export class ManimShell { * and needs to know at which line to start it. * @param handler Event handler for command execution events. See the * interface `CommandExecutionEventHandler`. + * + * @throws NoActiveShellError If no active shell is found, but an active + * shell is required for the command execution (when `errorOnNoActiveShell` + * is set to true). */ private async execCommand( command: string, @@ -189,9 +204,7 @@ export class ManimShell { } if (errorOnNoActiveShell && !this.hasActiveShell()) { - window.showWarningMessage( - "No active Manim session found, which is required for this command."); - return; + throw new NoActiveShellError(); } if (this.shouldLockDuringCommandExecution && !forceExecute && this.isExecutingCommand) { diff --git a/src/startStopScene.ts b/src/startStopScene.ts index 4b507e5f..12d0326b 100644 --- a/src/startStopScene.ts +++ b/src/startStopScene.ts @@ -108,11 +108,9 @@ export async function startScene(lineStart?: number) { * and the IPython terminal. */ export async function exitScene() { - await ManimShell.instance.executeCommandEnsureActiveSession("exit()", false, true); - // TODO: Implement error handling, and if no error resetActiveShell() (!!!) - // if (success) { - // ManimShell.instance.resetActiveShell(); - // } else { - // window.showErrorMessage('No active ManimGL scene found to exit.'); - // } + try { + await ManimShell.instance.executeCommandErrorOnNoActiveSession("exit()", false, true); + } catch(NoActiveSessionError) { + window.showErrorMessage('No active Manim session found to exit.'); + } } From 0116318fcb286c4faebfdb199ac595c2d0f6fe6f Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 12:48:28 +0100 Subject: [PATCH 47/58] Remove unnecessary constructor --- src/manimShell.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 24dd2d56..94d309d1 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -53,12 +53,7 @@ export interface CommandExecutionEventHandler { * Error thrown when no active shell is found, but an active shell is required * for the command execution. */ -export class NoActiveShellError extends Error { - constructor() { - super(); - this.name = "NoActiveShellError"; - } -} +export class NoActiveShellError extends Error { } /** * Wrapper around the IPython terminal that ManimGL uses. Ensures that commands From 7cd8bede302df48d0fde28fe1496b560ab68fd78 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 13:22:26 +0100 Subject: [PATCH 48/58] Add docstring to DATA event --- src/manimShell.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 821bb08a..4d0dd8e7 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -47,7 +47,11 @@ enum ManimShellEvent { */ KEYBOARD_INTERRUPT = 'keyboardInterrupt', - DATA = 'generalData' + /** + * Event emitted when data is received from the terminal, but stripped of + * ANSI control codes. + */ + DATA = 'ansiStrippedData' } /** From fbaaf00f5741f63aecd5b7ebce408e5c5e1eeea8 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 13:23:34 +0100 Subject: [PATCH 49/58] Remove leftover variables --- src/previewCode.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index 626d214a..d239967d 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -27,9 +27,6 @@ export async function previewCode(code: string, startLine: number): Promise Date: Sun, 27 Oct 2024 13:24:29 +0100 Subject: [PATCH 50/58] Finish progress even if error --- src/previewCode.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index d239967d..7d9dc0f5 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -23,12 +23,12 @@ const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; * @param code The code to preview (e.g. from a Manim cell or from a custom selection). */ export async function previewCode(code: string, startLine: number): Promise { + let progress: PreviewProgress | undefined; + try { const clipboardBuffer = await vscode.env.clipboard.readText(); await vscode.env.clipboard.writeText(code); - let progress: PreviewProgress | undefined; - await ManimShell.instance.executeCommand( PREVIEW_COMMAND, startLine, true, { onCommandIssued: () => { @@ -39,9 +39,8 @@ export async function previewCode(code: string, startLine: number): Promise Date: Sun, 27 Oct 2024 23:56:17 +0100 Subject: [PATCH 51/58] Keep track of IPython cell number --- src/manimShell.ts | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 5671fb7f..145970f3 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -98,6 +98,8 @@ export class ManimShell { private activeShell: Terminal | null = null; private eventEmitter = new EventEmitter(); + private iPythonCellCount: number = 0; + /** * Whether the execution of a new command is locked. This is used to prevent * multiple new scenes from being started at the same time, e.g. when users @@ -256,16 +258,19 @@ export class ManimShell { const dataListener = (data: string) => { handler?.onData?.(data); }; this.eventEmitter.on(ManimShellEvent.DATA, dataListener); + let currentExecutionCount = this.iPythonCellCount; + console.log(`💨: ${currentExecutionCount}`); + this.exec(shell, command); handler?.onCommandIssued?.(); - if (waitUntilFinished) { - await this.waitUntilCommandFinished(); - } - this.waitUntilCommandFinished(() => { + this.waitUntilCommandFinished(currentExecutionCount, () => { this.isExecutingCommand = false; this.eventEmitter.off(ManimShellEvent.DATA, dataListener); }); + if (waitUntilFinished) { + await this.waitUntilCommandFinished(currentExecutionCount); + } } /** @@ -319,7 +324,9 @@ export class ManimShell { * command execution. */ public resetActiveShell() { + this.iPythonCellCount = 0; this.activeShell = null; + this.eventEmitter.removeAllListeners(); } /** @@ -376,6 +383,7 @@ export class ManimShell { * @param command The command to execute in the shell. */ private exec(shell: Terminal, command: string) { + console.log(`🌟: ${command}`); this.detectShellExecutionEnd = false; if (shell.shellIntegration) { shell.shellIntegration.executeCommand(command); @@ -402,16 +410,19 @@ export class ManimShell { return this.activeShell as Terminal; } - private async waitUntilCommandFinished(callback?: () => void) { - await new Promise(resolve => { + private async waitUntilCommandFinished( + currentExecutionCount: number, callback?: () => void) { + await new Promise(resolve => { this.eventEmitter.once(ManimShellEvent.KEYBOARD_INTERRUPT, resolve); - // The first IPython cell is actually printing the issued command, - // which we want to just ignore, and then we wait for the actual - // command to finish. - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, () => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); - }); + const listener = () => { + console.log(`🎵: ${this.iPythonCellCount} vs. ${currentExecutionCount}`); + if (this.iPythonCellCount > currentExecutionCount) { + this.eventEmitter.off(ManimShellEvent.IPYTHON_CELL_FINISHED, listener); + resolve(); + } + }; + this.eventEmitter.on(ManimShellEvent.IPYTHON_CELL_FINISHED, listener); }); if (callback) { callback(); @@ -439,6 +450,8 @@ export class ManimShell { async (event: vscode.TerminalShellExecutionStartEvent) => { const stream = event.execution.read(); for await (const data of withoutAnsiCodes(stream)) { + console.log(`🎯: ${data}`); + this.eventEmitter.emit(ManimShellEvent.DATA, data); if (data.match(MANIM_WELCOME_REGEX)) { @@ -449,7 +462,11 @@ export class ManimShell { this.eventEmitter.emit(ManimShellEvent.KEYBOARD_INTERRUPT); } - if (data.match(IPYTHON_CELL_START_REGEX)) { + let ipythonMatch = data.match(IPYTHON_CELL_START_REGEX); + if (ipythonMatch) { + const cellNumber = parseInt(ipythonMatch[0].match(/\d+/)![0]); + this.iPythonCellCount = cellNumber; + console.log(`🎧: ${cellNumber}`); this.eventEmitter.emit(ManimShellEvent.IPYTHON_CELL_FINISHED); } From f51333494ffab978a57eb7584f60c80cc697fabf Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 27 Oct 2024 23:56:46 +0100 Subject: [PATCH 52/58] Use await for previewCode (right now not needed, but just to make intentions clear) --- src/extension.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 4078ee35..35bd6567 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -57,7 +57,7 @@ export function deactivate() { } * (when accessed via the command pallette) or the code of the cell where * the codelens was clicked. */ -function previewManimCell(cellCode?: string, startLine?: number) { +async function previewManimCell(cellCode?: string, startLine?: number) { let startLineFinal: number | undefined = startLine; // User has executed the command via command pallette @@ -86,13 +86,13 @@ function previewManimCell(cellCode?: string, startLine?: number) { return; } - previewCode(cellCode, startLineFinal); + await previewCode(cellCode, startLineFinal); } /** * Previews the Manim code of the selected text. */ -function previewSelection() { +async function previewSelection() { const editor = window.activeTextEditor; if (!editor) { window.showErrorMessage('Select some code to preview.'); @@ -119,7 +119,7 @@ function previewSelection() { return; } - previewCode(selectedText, selection.start.line); + await previewCode(selectedText, selection.start.line); } /** From 14bc6ae4d3adeb73bbb070612fef58d5ac9e4a29 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 28 Oct 2024 00:06:57 +0100 Subject: [PATCH 53/58] Use command waiting function in start command execution --- src/manimShell.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 145970f3..b2cb316b 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -313,9 +313,7 @@ export class ManimShell { // We are sure that the active shell is set since it is invoked // in `retrieveOrInitActiveShell()` or in the line above. this.exec(this.activeShell as Terminal, command); - await new Promise(resolve => { - this.eventEmitter.once(ManimShellEvent.IPYTHON_CELL_FINISHED, resolve); - }); + await this.waitUntilCommandFinished(this.iPythonCellCount); }); } From 701e9a9d2276f9e0c98bfb21bb155b956a207d4d Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 28 Oct 2024 00:10:20 +0100 Subject: [PATCH 54/58] Exit scene if Manim detected in new terminal --- src/manimShell.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index b2cb316b..9b32335c 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -453,6 +453,9 @@ export class ManimShell { this.eventEmitter.emit(ManimShellEvent.DATA, data); if (data.match(MANIM_WELCOME_REGEX)) { + if (this.activeShell && this.activeShell !== event.terminal) { + exitScene(); // Manim detected in new terminal + } this.activeShell = event.terminal; } From f25e8f4eb611ed1e0b93161311c9d02ccf9c1d98 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 28 Oct 2024 00:16:19 +0100 Subject: [PATCH 55/58] Add docstrings to waitUntilCommandFinished --- src/manimShell.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index 9b32335c..816ce7cd 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -408,6 +408,19 @@ export class ManimShell { return this.activeShell as Terminal; } + /** + * Waits until the current command has finished executing by waiting for + * the start of the next IPython cell. This is used to ensure that the + * command has actually finished executing, e.g. when the whole animation + * has been previewed. + * + * @param currentExecutionCount The current IPython cell count when the + * command was issued. This is used to detect when the next cell has started. + * @param callback An optional callback that is invoked when the command + * has finished executing. This is useful when the caller does not want to + * await the async function, but still wants to execute some code after the + * command has finished. + */ private async waitUntilCommandFinished( currentExecutionCount: number, callback?: () => void) { await new Promise(resolve => { From ddf1c1921ae80e58c66edd5bbe9b36ae1a1abed7 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 28 Oct 2024 00:17:25 +0100 Subject: [PATCH 56/58] Add docstring to iPythonCellCount --- src/manimShell.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/manimShell.ts b/src/manimShell.ts index 816ce7cd..00c251d8 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -98,6 +98,11 @@ export class ManimShell { private activeShell: Terminal | null = null; private eventEmitter = new EventEmitter(); + /** + * The current IPython cell count. Updated whenever a cell indicator is + * detected in the terminal output. This is used to determine when a new cell + * has started, i.e. when the command has finished executing. + */ private iPythonCellCount: number = 0; /** From e47d259cde2fd03fc6b5156151d29218bfaf0fde Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 28 Oct 2024 00:25:20 +0100 Subject: [PATCH 57/58] Add docstrings to previewCode --- src/previewCode.ts | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/previewCode.ts b/src/previewCode.ts index 7d9dc0f5..1b244679 100644 --- a/src/previewCode.ts +++ b/src/previewCode.ts @@ -21,6 +21,8 @@ const PREVIEW_COMMAND = `\x0C checkpoint_paste()\x1b`; * [3] https://youtu.be/rbu7Zu5X1zI * * @param code The code to preview (e.g. from a Manim cell or from a custom selection). + * @param startLine The line number in the active editor where the Manim session + * should start in case a new terminal is spawned. Also see `startScene(). */ export async function previewCode(code: string, startLine: number): Promise { let progress: PreviewProgress | undefined; @@ -44,6 +46,11 @@ export async function previewCode(code: string, startLine: number): Promise { @@ -51,13 +58,23 @@ function restoreClipboard(clipboardBuffer: string) { }, timeout); } +/** + * Class to handle the progress notification of the preview code command. + */ class PreviewProgress { private eventEmitter = new EventEmitter(); private FINISH_EVENT = "finished"; private REPORT_EVENT = "report"; + /** + * Current progress of the preview command. + */ private progress: number = 0; - private sceneName: string | undefined; + + /** + * Name of the animation being previewed. + */ + private animationName: string | undefined; constructor() { vscode.window.withProgress({ @@ -80,6 +97,12 @@ class PreviewProgress { }); } + /** + * Updates the progress based on the given Manim preview output. + * E.g. `2 ShowCreationNumberPlane, etc.: 7%| 2 /30 16.03it/s` + * + * @param data The Manim preview output to parse. + */ public reportOnData(data: string) { const newProgress = this.extractProgressFromString(data); if (newProgress === -1) { @@ -91,24 +114,34 @@ class PreviewProgress { if (split.length < 2) { return; } - let newSceneName = data.split(" ")[1]; + let newAnimName = data.split(" ")[1]; // remove last char which is a ":" - newSceneName = newSceneName.substring(0, newSceneName.length - 1); - if (newSceneName !== this.sceneName) { + newAnimName = newAnimName.substring(0, newAnimName.length - 1); + if (newAnimName !== this.animationName) { progressIncrement = -this.progress; // reset progress to 0 - this.sceneName = newSceneName; + this.animationName = newAnimName; } this.eventEmitter.emit(this.REPORT_EVENT, { increment: progressIncrement, - message: newSceneName + message: newAnimName }); } + /** + * Finishes the progress notification, i.e. closes the progress bar. + */ public finish() { this.eventEmitter.emit(this.FINISH_EVENT); } + /** + * Extracts the progress information from the given Manim preview output. + * + * @param data The Manim preview output to parse. + * @returns The progress percentage as in the interval [0, 100], + * or -1 if no progress information was found. + */ private extractProgressFromString(data: string): number { if (!data.includes("%")) { return -1; From 3b8173456c19ad3aa830bf1cccb7d7b0dfd9d21b Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 28 Oct 2024 00:34:36 +0100 Subject: [PATCH 58/58] Remove TODO note (is explained in PR comment) --- src/manimShell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manimShell.ts b/src/manimShell.ts index 00c251d8..510a4ac6 100644 --- a/src/manimShell.ts +++ b/src/manimShell.ts @@ -245,7 +245,7 @@ export class ManimShell { return; } - this.sendKeyboardInterrupt(); // TODO: explain why we send this manually + this.sendKeyboardInterrupt(); await new Promise(resolve => setTimeout(resolve, 500)); }