diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 4dc23996f..69fce4b50 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,30 +1,31 @@ import { closeBrackets } from '@codemirror/autocomplete'; -export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands'; -// import { search, highlightSelectionMatches } from '@codemirror/search'; -import { indentWithTab } from '@codemirror/commands'; +import { indentWithTab, toggleLineComment } from '@codemirror/commands'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; -import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language'; +import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { Compartment, EditorState, Prec } from '@codemirror/state'; import { + drawSelection, EditorView, - highlightActiveLineGutter, highlightActiveLine, + highlightActiveLineGutter, keymap, lineNumbers, - drawSelection, } from '@codemirror/view'; -import { repl, registerControl } from '@strudel/core'; -import { Drawer, cleanupDraw } from '@strudel/draw'; +import { persistentAtom } from '@nanostores/persistent'; +import { logger, registerControl, repl } from '@strudel/core'; +import { cleanupDraw, Drawer } from '@strudel/draw'; + import { isAutoCompletionEnabled } from './autocomplete.mjs'; -import { isTooltipEnabled } from './tooltip.mjs'; +import { basicSetup } from './basicSetup.mjs'; import { flash, isFlashEnabled } from './flash.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { keybindings } from './keybindings.mjs'; -import { initTheme, activateTheme, theme } from './themes.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; -import { widgetPlugin, updateWidgets } from './widget.mjs'; -import { persistentAtom } from '@nanostores/persistent'; -import { basicSetup } from './basicSetup.mjs'; +import { activateTheme, initTheme, theme } from './themes.mjs'; +import { isTooltipEnabled } from './tooltip.mjs'; +import { updateWidgets, widgetPlugin } from './widget.mjs'; + +export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; const extensions = { isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), @@ -94,8 +95,8 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo }), sliderPlugin, widgetPlugin, - // indentOnInput(), // works without. already brought with javascript extension? - // bracketMatching(), // does not do anything + // indentOnInput(), // works without. already brought with javascript + // extension? bracketMatching(), // does not do anything syntaxHighlighting(defaultHighlightStyle), EditorView.updateListener.of((v) => onChange(v)), drawSelection({ cursorBlinkRate: 0 }), @@ -119,13 +120,13 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo run: () => onStop?.(), }, /* { - key: 'Ctrl-Shift-.', - run: () => (onPanic ? onPanic() : onStop?.()), - }, - { - key: 'Ctrl-Shift-Enter', - run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()), - }, */ + key: 'Ctrl-Shift-.', + run: () => (onPanic ? onPanic() : onStop?.()), + }, + { + key: 'Ctrl-Shift-Enter', + run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()), + }, */ ]), ), ], @@ -206,7 +207,8 @@ export class StrudelMirror { updateWidgets(this.editor, widgets); updateMiniLocations(this.editor, this.miniLocations); replOptions?.afterEval?.(options); - // if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting) + // if no painters are set (.onPaint was not called), then we only need + // the present moment (for highlighting) const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0]; this.drawer.setDrawTime(drawTime); // invalidate drawer after we've set the appropriate drawTime @@ -245,6 +247,33 @@ export class StrudelMirror { } }; document.addEventListener('start-repl', this.onStartRepl); + + // Handle global evaluation requests (e.g., from Vim :w) + this.onEvaluateRequest = (e) => { + try { + // Evaluate current editor on repl-evaluate + logger('[repl] evaluate via event'); + this.evaluate(); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-evaluate event', err); + } + }; + document.addEventListener('repl-evaluate', this.onEvaluateRequest); + document.addEventListener('repl-stop', this.onStopRequest); + + // Toggle comments requested from Vim (gc) + this.onToggleComment = (e) => { + try { + // Honor selections; toggleLineComment handles both selections and + // single line + toggleLineComment(this.editor); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-toggle-comment event', err); + } + }; + document.addEventListener('repl-toggle-comment', this.onToggleComment); } draw(haps, time, painters) { painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime)); @@ -271,6 +300,16 @@ export class StrudelMirror { async stop() { this.repl.scheduler.stop(); } + + // Listen for global stop requests (e.g., from Vim :q) + onStopRequest = (e) => { + try { + this.stop(); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-stop event', err); + } + }; async toggle() { if (this.repl.scheduler.started) { this.repl.stop(); @@ -346,11 +385,18 @@ export class StrudelMirror { } } setCode(code) { - const changes = { from: 0, to: this.editor.state.doc.length, insert: code }; + const changes = { + from: 0, + to: this.editor.state.doc.length, + insert: code, + }; this.editor.dispatch({ changes }); } clear() { this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); + this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); + this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest); + this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment); } getCursorLocation() { return this.editor.state.selection.main.head; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index ca5f34f4c..81dc87a52 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -1,11 +1,12 @@ +import { defaultKeymap } from '@codemirror/commands'; import { Prec } from '@codemirror/state'; import { keymap, ViewPlugin } from '@codemirror/view'; // import { searchKeymap } from '@codemirror/search'; import { emacs } from '@replit/codemirror-emacs'; -import { vim } from '@replit/codemirror-vim'; +import { vim, Vim } from '@replit/codemirror-vim'; // import { vim } from './vim_test.mjs'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; -import { defaultKeymap } from '@codemirror/commands'; +import { logger } from '@strudel/core'; const vscodePlugin = ViewPlugin.fromClass( class { @@ -19,6 +20,110 @@ const vscodePlugin = ViewPlugin.fromClass( ); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); +// Map Vim :w to trigger the same action as evaluation. We dispatch a custom +// event 'repl-evaluate' that the editor listens for, and also simulate +// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it +// appears in the Console panel. +try { + if (Vim && typeof Vim.defineEx === 'function') { + // Map gc to toggle line comments by dispatching a custom event that our + // CodeMirror integration listens to. This avoids depending on Vim's + // internal actions and works with current selections/visual mode. + try { + Vim.defineAction('strudelToggleComment', (cm) => { + const view = cm?.view || cm; + try { + const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); + document.dispatchEvent(ev); + } catch (e) { + console.error('strudelToggleComment dispatch failed', e); + } + }); + Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' }); + Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' }); + } catch (e) { + console.error('Vim gc mapping failed', e); + } + + // :q to pause/stop + Vim.defineEx('quit', 'q', (cm) => { + try { + const view = cm?.view || cm; + // First try dispatching our custom stop event, then fallback to Alt+. + let handled = false; + try { + const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true }); + handled = document.dispatchEvent(ev) === false; + } catch (e) { + console.error('Error dispatching repl-stop event', e); + } + if (!handled) { + const altDot = new KeyboardEvent('keydown', { + key: '.', + code: 'Period', + altKey: true, + bubbles: true, + cancelable: true, + }); + view?.dom?.dispatchEvent?.(altDot); + } + } catch (e) { + console.error('Error dispatching :q stop event', e); + } + }); + + // :w to evaluate + Vim.defineEx('write', 'w', (cm) => { + const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself + try { + view?.focus?.(); + // Let the app know this came from Vim :w + try { + logger('[vim] :w — evaluating code'); + } catch (e) { + console.error('Error logging Vim :w evaluation', e); + } + // Dispatch a dedicated evaluate event first + let handled = false; + try { + const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true }); + handled = document.dispatchEvent(ev) === false; // false means preventDefault was called + } catch (e) { + console.error('Error dispatching repl-evaluate event', e); + } + if (handled) { + return; + } + // Try Ctrl+Enter first if not handled by custom event + const ctrlEnter = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + view?.dom?.dispatchEvent?.(ctrlEnter); + // If not handled (no handler called preventDefault), try Alt+Enter as + // fallback + if (!ctrlEnter.defaultPrevented) { + const altEnter = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + altKey: true, + bubbles: true, + cancelable: true, + }); + view?.dom?.dispatchEvent?.(altEnter); + } + } catch (e) { + console.error('Error dispatching :w evaluation event', e); + } + }); + } +} catch (e) { + console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e); +} + const keymaps = { vim, emacs, diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx index 8c4287af5..f74a4fb8b 100644 --- a/website/src/pages/technical-manual/repl.mdx +++ b/website/src/pages/technical-manual/repl.mdx @@ -117,6 +117,10 @@ Here's an example AST for `c3 [e3 g3]` which translates to `seq(c3, seq(e3, g3))` +## Vim Keybindings + +See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim) + ## Scheduling Events After an instance of `Pattern` is obtained from the user code, diff --git a/website/src/pages/technical-manual/vim.mdx b/website/src/pages/technical-manual/vim.mdx new file mode 100644 index 000000000..fa53aa550 --- /dev/null +++ b/website/src/pages/technical-manual/vim.mdx @@ -0,0 +1,37 @@ +--- +title: Vim Shortcuts +layout: ../../layouts/MainLayout.astro +--- + +# Vim Shortcuts in the REPL + +When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available: + +- :w — Evaluate the current code + + - Triggers the same evaluation as Ctrl+Enter / Alt+Enter + - You'll see messages in the Console panel such as: + - [vim] :w — evaluating code + - [repl] evaluate via event + - [eval] code updated + +- :q — Stop/pause playback + + - Triggers the same stop action as Alt+. + - Useful to quickly stop scheduling without leaving Vim mode + +- gc — Toggle line comments for the current selection(s) + + - Works in normal and visual mode + - If there's a selection, all selected lines are toggled + +Notes + +- Behavior respects the current language mode in the editor for comment syntax. +- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor. +- If you don't see the Console panel, open the right panel in the REPL UI. + +Troubleshooting + +- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback. +- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.